name: CI on: pull_request: types: [opened, synchronize, reopened] push: # Ref: GHA Filter pattern syntax: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#filter-pattern-cheat-sheet # Run on pushes to main, release branches, and previous/future major version branches branches: - main - 'v[0-9]+.*' # Matches any release branch, e.g. v6.0.3, v12.1.0 - '[0-9]+.x' # Matches any major version branch, e.g. 5.x, 23.x tags-ignore: - '**' # Tags handled by ci-release.yml workflow_call: # Called by ci-release.yml for uninterruptible release CI env: FORCE_COLOR: 1 HEAD_COMMIT: ${{ github.sha }} NODE_VERSION: 22.18.0 # Disable v8-compile-cache to prevent intermittent V8 deserializer crashes # when multiple parallel Nx workers race to read/write shared bytecode cache # files. The cache lives in /tmp and is discarded after each run anyway, # so disabling it has no meaningful performance impact in CI. # See: https://github.com/nodejs/node/issues/51555 DISABLE_V8_COMPILE_CACHE: 1 concurrency: group: ${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: job_setup: name: Setup runs-on: ubuntu-latest timeout-minutes: 15 env: IS_MAIN: ${{ github.ref == 'refs/heads/main' }} IS_TAG: ${{ startsWith(github.ref, 'refs/tags/v') }} IS_DEVELOPMENT: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/6.x' }} IS_SIX: ${{ github.ref == 'refs/heads/6.x' }} IS_SIX_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref == '6.x' }} permissions: actions: read contents: read # Required by dorny/paths-filter, which calls pulls.listFiles on # pull_request events. Private forks of this repo don't grant this # implicitly when an explicit permissions block is present, so it must # be listed here for the Setup job to succeed on those forks. pull-requests: read steps: - name: Checkout current commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.HEAD_COMMIT }} fetch-depth: 0 # fetch a treeless clone to improve checkout speed, the job will fetch contents later if needed filter: 'tree:0' - name: Output GitHub context if: env.RUNNER_DEBUG == '1' run: | echo "GITHUB_EVENT_NAME: ${{ github.event_name }}" echo "GITHUB_CONTEXT: ${{ toJson(github.event) }}" - name: Set SHAs for Nx Commands if: env.IS_TAG != 'true' uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 with: main-branch-name: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} error-on-no-successful-workflow: ${{ env.IS_MAIN == 'true' }} - name: Check user org membership id: check_user_org_membership if: github.event_name == 'pull_request' run: | echo "Looking up: ${{ github.triggering_actor }}" ENCODED_USERNAME=$(printf '%s' '${{ github.triggering_actor }}' | jq -sRr @uri) LOOKUP_USER=$(curl --write-out "%{http_code}" --silent --output /dev/null --location "https://api.github.com/orgs/tryghost/members/$ENCODED_USERNAME" --header "Authorization: Bearer ${{ secrets.CANARY_DOCKER_BUILD }}") if [ "$LOOKUP_USER" == "204" ]; then echo "User is in the org" echo "is_member=true" >> $GITHUB_OUTPUT else echo "User is not in the org" echo "is_member=false" >> $GITHUB_OUTPUT fi - name: Determine added packages if: env.IS_TAG != 'true' uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: added with: base: ${{ env.NX_BASE }} filters: | new-package: - added: 'ghost/**/package.json' - name: Determine changed packages if: env.IS_TAG != 'true' uses: AurorNZ/paths-filter@c9dd42e99db87803313ff6f4b1150cc9f6c836af # v5.0.0 id: changed with: base: ${{ env.NX_BASE }} filters: | shared: &shared - '.github/**' - '.npmrc' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' core: - *shared - 'ghost/**' - '!ghost/admin/**' - '!ghost/core/core/server/data/tinybird/**' admin: - *shared - 'ghost/admin/**' admin-x-settings: - *shared - 'apps/admin-x-settings/**' - 'apps/admin-x-design-system/**' - 'apps/admin-x-framework/**' - 'apps/shade/**' activitypub: - *shared - 'apps/shade/**' - 'apps/admin-x-framework/**' - 'apps/activitypub/**' announcement-bar: - *shared - 'apps/announcement-bar/**' comments-ui: - *shared - 'apps/comments-ui/**' portal: - *shared - 'apps/portal/**' signup-form: - *shared - 'apps/signup-form/**' sodo-search: - *shared - 'apps/sodo-search/**' tinybird: - '.github/workflows/ci.yml' - 'compose.dev.analytics.yaml' - 'ghost/core/core/server/data/tinybird/**' - '!ghost/core/core/server/data/tinybird/**/*.md' tinybird-datafiles: - 'ghost/core/core/server/data/tinybird/**' - '!ghost/core/core/server/data/tinybird/**/*.md' any-code: - '!**/*.md' - name: Define Node test matrix id: node_matrix run: | echo 'matrix=["22.18.0"]' >> $GITHUB_OUTPUT - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Set up Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Determine Affected Projects if: env.IS_TAG != 'true' id: affected run: | AFFECTED_PROJECTS=$(pnpm -s nx show projects --affected --json | tr -d '\n') echo "affected_projects=$AFFECTED_PROJECTS" >> "$GITHUB_OUTPUT" outputs: affected_projects: ${{ steps.affected.outputs.affected_projects }} changed_admin: ${{ steps.changed.outputs.admin }} changed_core: ${{ steps.changed.outputs.core }} changed_admin_x_settings: ${{ steps.changed.outputs.admin-x-settings }} changed_activitypub: ${{ steps.changed.outputs.activitypub }} changed_announcement_bar: ${{ steps.changed.outputs.announcement-bar }} changed_comments_ui: ${{ steps.changed.outputs.comments-ui }} changed_portal: ${{ steps.changed.outputs.portal }} changed_signup_form: ${{ steps.changed.outputs.signup-form }} changed_sodo_search: ${{ steps.changed.outputs.sodo-search }} changed_tinybird: ${{ steps.changed.outputs.tinybird }} changed_tinybird_datafiles: ${{ steps.changed.outputs.tinybird-datafiles }} changed_any_code: ${{ steps.changed.outputs.any-code }} changed_new_package: ${{ steps.added.outputs.new-package }} is_main: ${{ env.IS_MAIN }} is_tag: ${{ env.IS_TAG }} is_development: ${{ env.IS_DEVELOPMENT }} is_six: ${{ env.IS_SIX }} is_six_pr: ${{ env.IS_SIX_PR }} member_is_in_org: ${{ steps.check_user_org_membership.outputs.is_member }} node_version: ${{ env.NODE_VERSION }} node_test_matrix: ${{ steps.node_matrix.outputs.matrix }} nx_base: ${{ env.NX_BASE }} job_app_version_bump_check: name: Check app version bump runs-on: ubuntu-latest needs: [job_setup] if: github.event_name == 'pull_request' steps: - name: Checkout PR head commit uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 - name: Fetch main branch run: git fetch --no-tags origin main - name: Check app version bump env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_COMPARE_SHA: ${{ github.event.pull_request.head.sha }} run: node .github/scripts/check-app-version-bump.js job_lint: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_any_code == 'true' name: Lint steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1000 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ghost/**/.eslintcache key: eslint-cache - name: Lint all (tags) if: needs.job_setup.outputs.is_tag == 'true' run: pnpm nx run-many -t lint - name: Lint affected (branches) if: needs.job_setup.outputs.is_tag != 'true' run: pnpm nx affected -t lint env: NX_BASE: ${{ needs.job_setup.outputs.nx_base }} NX_HEAD: ${{ env.HEAD_COMMIT }} - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_i18n: runs-on: ubuntu-latest needs: [job_setup] name: i18n if: | needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_comments_ui == 'true' || needs.job_setup.outputs.changed_signup_form == 'true' || needs.job_setup.outputs.changed_sodo_search == 'true' || needs.job_setup.outputs.changed_portal == 'true' || needs.job_setup.outputs.changed_core == 'true' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Run i18n tests run: pnpm nx run @tryghost/i18n:test job_admin-tests: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_admin == 'true' name: Admin tests - Chrome env: MOZ_HEADLESS: 1 JOBS: 1 CI: true COVERAGE: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - run: pnpm nx run ghost-admin:test env: BROWSER: Chrome # Merge coverage reports and upload - name: Merge Admin test coverage run: pnpm ember coverage-merge working-directory: ghost/admin - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: admin-coverage path: ghost/*/coverage/cobertura-coverage.xml - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_unit-tests: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_any_code == 'true' strategy: matrix: node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} name: Unit tests (Node ${{ matrix.node }}) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 1000 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ matrix.node }} cache: pnpm - name: Install dependencies # sqlite3 is an optionalDependency. Without --force, pnpm may skip # installing/linking it when restoring from a cached store. --force # ensures all optional deps are installed regardless. # (ghost core's test:unit job requires sqlite3) run: pnpm install --frozen-lockfile --force - name: Set timezone (non-UTC) uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0 with: timezoneLinux: "America/New_York" - name: Run unit tests (tags — all) if: needs.job_setup.outputs.is_tag == 'true' run: pnpm nx run-many -t test:unit env: FORCE_COLOR: 0 GHOST_UNIT_TEST_VARIANT: ci NX_SKIP_LOG_GROUPING: true logging__level: fatal - name: Run unit tests (branches — affected) if: needs.job_setup.outputs.is_tag != 'true' run: pnpm nx affected -t test:unit env: FORCE_COLOR: 0 GHOST_UNIT_TEST_VARIANT: ci NX_SKIP_LOG_GROUPING: true logging__level: fatal NX_BASE: ${{ needs.job_setup.outputs.nx_base }} NX_HEAD: ${{ env.HEAD_COMMIT }} - name: Check for unexpected file changes run: | if [ -n "$(git status --porcelain)" ]; then echo "Tests generated unexpected file changes. Commit them before merging:" git status git diff 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@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_acceptance-tests: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' services: mysql: image: ${{ matrix.env.DB == 'mysql8' && 'mysql:8.0' || '' }} env: MYSQL_DATABASE: ghost_testing MYSQL_ROOT_PASSWORD: root ports: - 3306 options: >- --tmpfs /var/lib/mysql --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot" --health-interval=10s --health-timeout=5s --health-retries=12 redis: image: redis:7.0 ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=12 strategy: matrix: node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} env: - DB: mysql8 NODE_ENV: testing-mysql include: - node: ${{ needs.job_setup.outputs.node_version }} env: DB: sqlite3 NODE_ENV: testing env: DB: ${{ matrix.env.DB }} NODE_ENV: ${{ matrix.env.NODE_ENV }} name: Acceptance tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ matrix.node }} cache: pnpm - name: Install dependencies # sqlite3 is an optionalDependency. Without --force, pnpm may skip # installing/linking it when restoring from a cached store. --force # ensures all optional deps are installed regardless. run: | if [ "${{ matrix.env.DB }}" = "sqlite3" ]; then pnpm install --frozen-lockfile --force else pnpm install --frozen-lockfile fi - name: Set timezone (non-UTC) uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0 with: timezoneLinux: "America/New_York" - name: Set env vars (SQLite) if: contains(matrix.env.DB, 'sqlite') run: echo "database__connection__filename=/dev/shm/ghost-test.db" >> $GITHUB_ENV - name: Set env vars (MySQL) if: contains(matrix.env.DB, 'mysql') run: | echo "database__connection__host=127.0.0.1" >> $GITHUB_ENV echo "database__connection__port=${{ job.services.mysql.ports['3306'] }}" >> $GITHUB_ENV echo "database__connection__password=root" >> $GITHUB_ENV - name: E2E tests run: pnpm nx run ghost:test:ci:e2e - name: Integration tests run: pnpm nx run ghost:test:ci:integration - name: Check for unexpected file changes run: | if [ -n "$(git status --porcelain)" ]; then echo "Tests generated unexpected file changes. Commit them before merging:" git status git diff exit 1 fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 if: matrix.node == env.NODE_VERSION && contains(matrix.env.DB, 'mysql') with: name: e2e-coverage path: | ghost/*/coverage-e2e/cobertura-coverage.xml ghost/*/coverage-integration/cobertura-coverage.xml - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_legacy-tests: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' services: mysql: image: ${{ matrix.env.DB == 'mysql8' && 'mysql:8.0' || '' }} env: MYSQL_DATABASE: ghost_testing MYSQL_ROOT_PASSWORD: root ports: - 3306 options: >- --tmpfs /var/lib/mysql --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot" --health-interval=10s --health-timeout=5s --health-retries=12 strategy: matrix: include: - node: ${{ needs.job_setup.outputs.node_version }} env: DB: mysql8 NODE_ENV: testing-mysql - node: ${{ needs.job_setup.outputs.node_version }} env: DB: sqlite3 NODE_ENV: testing env: DB: ${{ matrix.env.DB }} NODE_ENV: ${{ matrix.env.NODE_ENV }} name: Legacy tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ matrix.node }} cache: pnpm - name: Install dependencies # sqlite3 is an optionalDependency. Without --force, pnpm may skip # installing/linking it when restoring from a cached store. --force # ensures all optional deps are installed regardless. run: | if [ "${{ matrix.env.DB }}" = "sqlite3" ]; then pnpm install --frozen-lockfile --force else pnpm install --frozen-lockfile fi - name: Set env vars (SQLite) if: contains(matrix.env.DB, 'sqlite') run: echo "database__connection__filename=/dev/shm/ghost-test.db" >> $GITHUB_ENV - name: Set env vars (MySQL) if: contains(matrix.env.DB, 'mysql') run: | echo "database__connection__host=127.0.0.1" >> $GITHUB_ENV echo "database__connection__port=${{ job.services.mysql.ports['3306'] }}" >> $GITHUB_ENV echo "database__connection__password=root" >> $GITHUB_ENV - name: Legacy tests run: pnpm nx run ghost:test:ci:legacy - name: Check for unexpected file changes run: | if [ -n "$(git status --porcelain)" ]; then echo "Tests generated unexpected file changes. Commit them before merging:" git status git diff exit 1 fi - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_admin_x_settings: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_admin_x_settings == 'true' name: Admin-X Settings tests env: CI: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Setup Playwright uses: ./.github/actions/setup-playwright - run: pnpm nx run @tryghost/admin-x-settings:test:acceptance - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: admin-x-settings-playwright-report path: apps/admin-x-settings/playwright-report retention-days: 30 - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_activitypub: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_activitypub == 'true' name: ActivityPub tests env: CI: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Setup Playwright uses: ./.github/actions/setup-playwright - run: pnpm nx run @tryghost/activitypub:test:acceptance - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: activitypub-playwright-report path: apps/activitypub/playwright-report retention-days: 30 - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_comments_ui: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_comments_ui == 'true' name: Comments-UI tests env: CI: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Setup Playwright uses: ./.github/actions/setup-playwright - run: pnpm nx run @tryghost/comments-ui:test - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: comments-ui-playwright-report path: apps/comments-ui/playwright-report retention-days: 30 - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_signup_form: runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_signup_form == 'true' name: Signup-form tests env: CI: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Setup Playwright uses: ./.github/actions/setup-playwright - run: pnpm nx run @tryghost/signup-form:test:e2e - name: Upload test results if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: signup-form-playwright-report path: apps/signup-form/playwright-report retention-days: 30 - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_tinybird-tests: name: Tinybird Tests runs-on: ubuntu-latest needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_tinybird == 'true' defaults: run: working-directory: ghost/core/core/server/data/tinybird services: tinybird: image: tinybirdco/tinybird-local:latest@sha256:52ea15fc337547b13d06069c23479c293e23074d4e4a6be21253e4bd57ad12be ports: - 7181:7181 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Tinybird CLI run: curl -fsSL https://tinybird.co/install.sh | sh - name: Build project run: tb build - name: Test project run: tb test run - name: Trigger and watch traffic analytics infra Tinybird workflow if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository env: GH_TOKEN: ${{ secrets.TRAFFIC_ANALYTICS_GITHUB_TOKEN }} uses: ./.github/actions/dispatch-workflow with: repo: TryGhost/traffic-analytics-infra workflow: tinybird.yml branch: main dispatch-inputs: >- { "ghost_ref": "${{ github.sha }}", "caller_run_id": "${{ github.run_id }}", "run_local_tests": false } job_ghost-cli: name: Ghost-CLI tests needs: [job_setup] if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install Ghost-CLI run: npm install -g ghost-cli@latest - name: Install dependencies run: pnpm install --frozen-lockfile - run: node .github/scripts/bump-version.js canary - run: pnpm archive - run: mv ghost-*.tgz ghost.tgz working-directory: ghost/core - name: Save Ghost CLI Debug Logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ghost-cli-debug-logs path: /home/runner/.ghost/logs/ - name: Clean Install run: | DIR=$(mktemp -d) ghost install local -d $DIR --archive $(pwd)/ghost/core/ghost.tgz - name: Latest Release run: | DIR=$(mktemp -d) ghost install local -d $DIR ghost update -d $DIR --archive $(pwd)/ghost/core/ghost.tgz - name: Print debug logs if: failure() run: | [ -f ~/.ghost/logs/*.log ] && cat ~/.ghost/logs/*.log - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_build_artifacts: name: Build & Publish Artifacts needs: [job_setup] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: submodules: true - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build server and admin assets run: | PKG_VERSION=$(node -p "require('./ghost/core/package.json').version") SHORT_SHA="${GITHUB_SHA:0:7}" if [ "${{ github.ref_type }}" != "tag" ]; then export GHOST_BUILD_VERSION="${PKG_VERSION}+${SHORT_SHA}" echo "GHOST_BUILD_VERSION=${GHOST_BUILD_VERSION}" >> $GITHUB_ENV fi pnpm build:production - name: Verify tag matches package.json if: startsWith(github.ref, 'refs/tags/v') working-directory: ghost/core run: | PKG_VERSION=$(node -p "require('./package.json').version") TAG_VERSION="${GITHUB_REF_NAME#v}" if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then echo "::error::Tag ${GITHUB_REF_NAME} doesn't match package.json version ${PKG_VERSION}" exit 1 fi - name: Pack standalone distribution run: pnpm --filter ghost pack:standalone - name: Create npm tarball if: startsWith(github.ref, 'refs/tags/v') run: pnpm --filter ghost pack:tarball - name: Upload npm tarball if: startsWith(github.ref, 'refs/tags/v') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ghost-npm-tarball path: ghost/core/ghost-*.tgz retention-days: 7 if-no-files-found: error - name: Prepare Docker build context run: mv ghost/core/package/ /tmp/ghost-production/ - name: Determine push strategy id: strategy run: | # Same-org repos (e.g. TryGhost/Ghost, TryGhost/Ghost-Security) push to GHCR. # External forks and cross-repo PRs use artifact-based image transfer instead. USE_ARTIFACT="false" if [ "${{ github.repository_owner }}" != "TryGhost" ]; then # External fork — no GHCR push USE_ARTIFACT="true" elif [ "${{ github.event_name }}" = "pull_request" ] && \ [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then # Cross-repo PR (fork PR into this repo) — no GHCR push USE_ARTIFACT="true" fi OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') # Derive GHCR image names from repository name so each repo gets its own namespace # TryGhost/Ghost → ghost-core / ghost, TryGhost/Ghost-Security → ghost-security-core / ghost-security REPO_NAME=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') if [ "$REPO_NAME" = "ghost" ]; then IMAGE_CORE_NAME="ghcr.io/${OWNER}/ghost-core" IMAGE_FULL_NAME="ghcr.io/${OWNER}/ghost" else IMAGE_CORE_NAME="ghcr.io/${OWNER}/${REPO_NAME}-core" IMAGE_FULL_NAME="ghcr.io/${OWNER}/${REPO_NAME}" fi # Force push on tag pushes (release images must always be published) IS_TAG="${{ startsWith(github.ref, 'refs/tags/v') }}" if [ "$IS_TAG" = "true" ]; then USE_ARTIFACT="false" fi echo "use-artifact=$USE_ARTIFACT" >> $GITHUB_OUTPUT echo "should-push=$( [ "$USE_ARTIFACT" = "false" ] && echo "true" || echo "false" )" >> $GITHUB_OUTPUT echo "owner=$OWNER" >> $GITHUB_OUTPUT echo "image-core-name=$IMAGE_CORE_NAME" >> $GITHUB_OUTPUT echo "image-full-name=$IMAGE_FULL_NAME" >> $GITHUB_OUTPUT echo "image-e2e-name=${IMAGE_FULL_NAME}-e2e" >> $GITHUB_OUTPUT - name: Upload admin artifact for CD id: upload-admin uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: admin-build-cd path: apps/admin/dist retention-days: 7 if-no-files-found: error - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Log in to GitHub Container Registry if: steps.strategy.outputs.should-push == 'true' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta (core) id: meta-core uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 with: images: ${{ steps.strategy.outputs.image-core-name }} tags: | type=ref,event=branch type=ref,event=pr type=sha type=semver,pattern=v{{version}} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} labels: | org.opencontainers.image.title=Ghost Core org.opencontainers.image.description=Ghost production build (server only, no admin) org.opencontainers.image.vendor=TryGhost - name: Docker meta (full) id: meta-full uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 with: images: ${{ steps.strategy.outputs.image-full-name }} tags: | type=ref,event=branch type=ref,event=pr type=sha type=semver,pattern=v{{version}} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} labels: | org.opencontainers.image.title=Ghost org.opencontainers.image.description=Ghost production build (server + admin) org.opencontainers.image.vendor=TryGhost - name: Build & push core image uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 with: context: /tmp/ghost-production file: Dockerfile.production target: core build-args: | NODE_VERSION=${{ env.NODE_VERSION }} GHOST_BUILD_VERSION=${{ env.GHOST_BUILD_VERSION }} push: ${{ steps.strategy.outputs.should-push }} load: ${{ steps.strategy.outputs.should-push == 'false' }} tags: ${{ steps.meta-core.outputs.tags }} labels: ${{ steps.meta-core.outputs.labels }} cache-from: type=registry,ref=${{ steps.strategy.outputs.image-core-name }}:cache-main cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-core-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} - name: Build & push full image uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 with: context: /tmp/ghost-production file: Dockerfile.production target: full build-args: | NODE_VERSION=${{ env.NODE_VERSION }} GHOST_BUILD_VERSION=${{ env.GHOST_BUILD_VERSION }} push: ${{ steps.strategy.outputs.should-push }} load: true tags: ${{ steps.meta-full.outputs.tags }} labels: ${{ steps.meta-full.outputs.labels }} cache-from: type=registry,ref=${{ steps.strategy.outputs.image-full-name }}:cache-main cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', steps.strategy.outputs.image-full-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} - name: Save full image as artifact run: | IMAGE_TAG=$(echo "${{ steps.meta-full.outputs.tags }}" | head -n1) echo "Saving image: $IMAGE_TAG" docker save "$IMAGE_TAG" | gzip > docker-image-production.tar.gz echo "Image saved as docker-image-production.tar.gz" ls -lh docker-image-production.tar.gz - name: Upload image artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: docker-image-production path: docker-image-production.tar.gz retention-days: 1 - name: Inspect image size and layers shell: bash run: | IMAGE_TAG=$(echo "${{ steps.meta-full.outputs.tags }}" | head -n1) echo "Analyzing Docker image: $IMAGE_TAG" # Get the image size in bytes IMAGE_SIZE_BYTES=$(docker inspect "$IMAGE_TAG" --format='{{.Size}}') # Convert to human readable format IMAGE_SIZE_MB=$(( IMAGE_SIZE_BYTES / 1024 / 1024 )) IMAGE_SIZE_GB=$(echo "scale=2; $IMAGE_SIZE_BYTES / 1024 / 1024 / 1024" | bc) # Format size display based on magnitude if [ $IMAGE_SIZE_MB -ge 1024 ]; then IMAGE_SIZE_DISPLAY="${IMAGE_SIZE_GB} GB" else IMAGE_SIZE_DISPLAY="${IMAGE_SIZE_MB} MB" fi echo "Image size: ${IMAGE_SIZE_DISPLAY}" # Write to GitHub Step Summary { echo "# Docker Image Analysis" echo "" echo "**Image:** \`$IMAGE_TAG\`" echo "" echo "**Total Size:** ${IMAGE_SIZE_DISPLAY}" echo "" echo "## Image Layers" echo "" echo "| Size | Layer |" echo "|------|-------|" # Get all layers (including 0B ones) docker history "$IMAGE_TAG" --format "{{.Size}}@@@{{.CreatedBy}}" --no-trunc | \ while IFS='@@@' read -r size cmd; do # Clean up the command for display cmd_clean=$(echo "$cmd" | sed 's/^\/bin\/sh -c //' | sed 's/^#(nop) //' | sed 's/^@@//' | sed 's/|/\\|/g' | cut -c1-80) if [ ${#cmd} -gt 80 ]; then cmd_clean="${cmd_clean}..." fi echo "| $size | \`${cmd_clean}\` |" done } >> $GITHUB_STEP_SUMMARY outputs: image-tags: ${{ steps.meta-full.outputs.tags }} use-artifact: ${{ steps.strategy.outputs.use-artifact }} admin-artifact-id: ${{ steps.upload-admin.outputs.artifact-id }} image-e2e-name: ${{ steps.strategy.outputs.image-e2e-name }} job_build_e2e_public_apps: name: Build E2E Public App Assets needs: [job_setup] runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 env: FORCE_COLOR: 0 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build public apps for E2E run: pnpm --filter @tryghost/e2e build:apps - name: Pack public app artifacts run: | tar -czf e2e-public-apps.tar.gz \ apps/portal/umd \ apps/comments-ui/umd \ apps/sodo-search/umd \ apps/signup-form/umd \ apps/announcement-bar/umd ls -lh e2e-public-apps.tar.gz - name: Upload public app artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-public-apps path: e2e-public-apps.tar.gz retention-days: 1 job_build_e2e_image: name: Build E2E Docker Image needs: [job_setup, job_build_e2e_public_apps, job_build_artifacts] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download public app artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: e2e-public-apps - name: Extract public app artifacts run: tar -xzf e2e-public-apps.tar.gz - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 with: # Fork/cross-repo PRs use artifact transfer (no GHCR push). The default # docker-container driver runs in an isolated BuildKit container that # cannot see locally loaded images, so we fall back to the docker driver # which shares the host daemon's image store. driver: ${{ needs.job_build_artifacts.outputs.use-artifact == 'true' && 'docker' || '' }} - name: Load base Ghost image uses: ./.github/actions/load-docker-image id: load-base with: use-artifact: ${{ needs.job_build_artifacts.outputs.use-artifact }} image-tags: ${{ needs.job_build_artifacts.outputs.image-tags }} artifact-name: docker-image-production - name: Determine E2E image distribution strategy id: strategy run: | USE_ARTIFACT="${{ needs.job_build_artifacts.outputs.use-artifact }}" SHOULD_PUSH="true" if [ "$USE_ARTIFACT" = "true" ]; then SHOULD_PUSH="false" fi echo "use-artifact=$USE_ARTIFACT" >> $GITHUB_OUTPUT echo "should-push=$SHOULD_PUSH" >> $GITHUB_OUTPUT - name: Log in to GitHub Container Registry if: steps.strategy.outputs.should-push == 'true' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta (e2e) id: meta-e2e uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 with: images: ${{ needs.job_build_artifacts.outputs.image-e2e-name }} tags: | type=ref,event=branch type=ref,event=pr type=sha type=raw,value=latest,enable={{is_default_branch}} labels: | org.opencontainers.image.title=Ghost E2E org.opencontainers.image.description=Ghost production build with public E2E app bundles org.opencontainers.image.vendor=TryGhost - name: Build & push E2E image uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 with: context: . file: e2e/Dockerfile.e2e build-args: | GHOST_IMAGE=${{ steps.load-base.outputs.image-tag }} push: ${{ steps.strategy.outputs.should-push }} load: ${{ steps.strategy.outputs.use-artifact == 'true' }} tags: ${{ steps.meta-e2e.outputs.tags }} labels: ${{ steps.meta-e2e.outputs.labels }} cache-from: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-main', needs.job_build_artifacts.outputs.image-e2e-name) || '' }} cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref={0}:cache-{1},mode=max', needs.job_build_artifacts.outputs.image-e2e-name, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} - name: Save E2E image as artifact if: steps.strategy.outputs.use-artifact == 'true' run: | IMAGE_TAG=$(echo "${{ steps.meta-e2e.outputs.tags }}" | head -n1) echo "Saving image: $IMAGE_TAG" docker save "$IMAGE_TAG" | gzip > docker-image-e2e.tar.gz echo "Image saved as docker-image-e2e.tar.gz" ls -lh docker-image-e2e.tar.gz - name: Upload E2E image artifact if: steps.strategy.outputs.use-artifact == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: docker-image-e2e path: docker-image-e2e.tar.gz retention-days: 1 outputs: image-tags: ${{ steps.meta-e2e.outputs.tags }} use-artifact: ${{ steps.strategy.outputs.use-artifact }} job_e2e_tests: name: E2E Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) runs-on: ubuntu-latest needs: [job_build_e2e_image, job_setup] strategy: fail-fast: true matrix: shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] shardTotal: [8] steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Pull or build Tinybird CLI Image run: | COMPOSE_IMAGE="${COMPOSE_PROJECT_NAME:-ghost-dev}-tb-cli" # Try pulling pre-built image from GHCR first (fast path) if docker pull ghcr.io/tryghost/tb-cli:latest 2>/dev/null; then echo "Pulled tb-cli from GHCR" docker tag ghcr.io/tryghost/tb-cli:latest "$COMPOSE_IMAGE" else echo "GHCR image not available, building from source" docker buildx build --load -t "$COMPOSE_IMAGE" -f docker/tb-cli/Dockerfile . fi - name: Load Image uses: ./.github/actions/load-docker-image id: load with: use-artifact: ${{ needs.job_build_e2e_image.outputs.use-artifact }} image-tags: ${{ needs.job_build_e2e_image.outputs.image-tags }} artifact-name: docker-image-e2e - name: Setup Docker Registry Mirrors uses: ./.github/actions/setup-docker-registry-mirrors - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Prepare E2E CI job env: GHOST_E2E_IMAGE: ${{ steps.load.outputs.image-tag }} GHOST_E2E_SKIP_IMAGE_BUILD: 'true' run: bash ./e2e/scripts/prepare-ci-e2e-job.sh - name: Run e2e tests in Playwright container env: TEST_WORKERS_COUNT: 1 GHOST_E2E_MODE: build GHOST_E2E_IMAGE: ${{ steps.load.outputs.image-tag }} E2E_SHARD_INDEX: ${{ matrix.shardIndex }} E2E_SHARD_TOTAL: ${{ matrix.shardTotal }} E2E_RETRIES: 2 run: bash ./e2e/scripts/run-playwright-container.sh - name: Dump E2E docker logs if: failure() run: bash ./e2e/scripts/dump-e2e-docker-logs.sh - name: Stop E2E infra if: always() run: pnpm --filter @tryghost/e2e infra:down - name: Upload blob report to GitHub Actions Artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: blob-report-${{ matrix.shardIndex }} path: e2e/blob-report retention-days: 1 - name: Upload test results artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results-${{ matrix.shardIndex }} path: e2e/test-results retention-days: 7 - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} job_merge_e2e_reports: name: Merge Reports if: always() needs: [job_e2e_tests, job_setup] runs-on: ubuntu-latest strategy: fail-fast: false steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Download blob reports from GitHub Actions Artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 continue-on-error: true with: path: e2e/all-blob-reports pattern: blob-report-* merge-multiple: true - name: Check for blob reports id: check run: | if [ -d "e2e/all-blob-reports" ] && [ -n "$(ls -A e2e/all-blob-reports 2>/dev/null)" ]; then echo "has_reports=true" >> $GITHUB_OUTPUT else echo "has_reports=false" >> $GITHUB_OUTPUT fi - name: Download test results from GitHub Actions Artifacts if: steps.check.outputs.has_reports == 'true' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: e2e/all-test-results pattern: test-results-* merge-multiple: true - name: Merge into HTML Report if: steps.check.outputs.has_reports == 'true' run: npx playwright merge-reports --reporter html ./all-blob-reports working-directory: e2e - name: Upload HTML report if: steps.check.outputs.has_reports == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: playwright-report path: e2e/playwright-report retention-days: 14 - name: Upload merged test results if: steps.check.outputs.has_reports == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: test-results path: e2e/all-test-results retention-days: 7 - name: View Test Report command if: steps.check.outputs.has_reports == 'true' run: | echo -e "::notice::To view the Playwright report locally, run:\n\nREPORT_DIR=\$(mktemp -d) && gh run download ${{ github.run_id }} -n playwright-report -D \"\$REPORT_DIR\" && npx playwright show-report \"\$REPORT_DIR\"" - name: Comment on PR with test report command if: github.event_name == 'pull_request' && steps.check.outputs.has_reports == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh pr comment ${{ github.event.pull_request.number }} --body "## E2E Tests Failed To view the Playwright test report locally, run: \`\`\`bash REPORT_DIR=\$(mktemp -d) && gh run download ${{ github.run_id }} -n playwright-report -D \"\$REPORT_DIR\" && npx playwright show-report \"\$REPORT_DIR\" \`\`\`" job_coverage: name: Coverage needs: [ job_admin-tests, job_acceptance-tests, job_unit-tests ] runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Restore Admin coverage if: contains(needs.job_admin-tests.result, 'success') uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: admin-coverage - name: Move coverage if: contains(needs.job_admin-tests.result, 'success') run: | rsync -av --remove-source-files admin/* ghost/admin - name: Upload Admin test coverage uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: flags: admin-tests - name: Restore E2E coverage if: contains(needs.job_acceptance-tests.result, 'success') uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: e2e-coverage - name: Move coverage if: contains(needs.job_acceptance-tests.result, 'success') run: | rsync -av --remove-source-files core/* ghost/core - name: Upload E2E test coverage if: contains(needs.job_acceptance-tests.result, 'success') uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: flags: e2e-tests job_required_tests: name: All required tests passed or skipped needs: [ job_setup, job_app_version_bump_check, job_lint, job_i18n, job_ghost-cli, job_admin-tests, job_unit-tests, job_acceptance-tests, job_legacy-tests, job_admin_x_settings, job_activitypub, job_comments_ui, job_signup_form, job_tinybird-tests, job_build_e2e_public_apps, job_build_e2e_image, job_e2e_tests, publish_packages ] if: always() runs-on: ubuntu-latest steps: - name: Output needs run: echo "${{ toJson(needs) }}" - name: Check if any required jobs failed or been cancelled if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: | echo "One of the dependent jobs have failed or been cancelled. You may need to re-run it." && exit 1 publish_packages: needs: [ job_setup, job_lint, job_unit-tests ] name: Publish ${{ matrix.package_name }} runs-on: ubuntu-latest if: always() && github.repository == 'TryGhost/Ghost' && needs.job_setup.result == 'success' && needs.job_lint.result == 'success' && needs.job_unit-tests.result == 'success' permissions: id-token: write strategy: matrix: include: - package_name: '@tryghost/activitypub' package_path: 'apps/activitypub' cdn_paths: 'https://cdn.jsdelivr.net/ghost/activitypub@CURRENT_MAJOR/dist/activitypub.js' - package_name: '@tryghost/portal' package_path: 'apps/portal' cdn_paths: 'https://cdn.jsdelivr.net/ghost/portal@~CURRENT_MINOR/umd/portal.min.js' - package_name: '@tryghost/sodo-search' package_path: 'apps/sodo-search' cdn_paths: | https://cdn.jsdelivr.net/ghost/sodo-search@~CURRENT_MINOR/umd/sodo-search.min.js https://cdn.jsdelivr.net/ghost/sodo-search@~CURRENT_MINOR/umd/main.css - package_name: '@tryghost/comments-ui' package_path: 'apps/comments-ui' cdn_paths: 'https://cdn.jsdelivr.net/ghost/comments-ui@~CURRENT_MINOR/umd/comments-ui.min.js' - package_name: '@tryghost/signup-form' package_path: 'apps/signup-form' cdn_paths: 'https://cdn.jsdelivr.net/ghost/signup-form@~CURRENT_MINOR/umd/signup-form.min.js' - package_name: '@tryghost/announcement-bar' package_path: 'apps/announcement-bar' cdn_paths: 'https://cdn.jsdelivr.net/ghost/announcement-bar@~CURRENT_MINOR/umd/announcement-bar.min.js' steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ env.NODE_VERSION }} cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Check if version changed if: needs.job_setup.outputs.is_main == 'true' id: version_check working-directory: ${{ matrix.package_path }} run: | CURRENT_VERSION=$(cat package.json | jq -r .version) echo "Current version: $CURRENT_VERSION" CURRENT_MINOR=$(cat package.json | jq -r .version | awk -F. '{print $1"."$2}') echo "current_minor=$CURRENT_MINOR" >> $GITHUB_OUTPUT CURRENT_MAJOR=$(cat package.json | jq -r .version | awk -F. '{print $1}') echo "current_major=$CURRENT_MAJOR" >> $GITHUB_OUTPUT PUBLISHED_VERSION=$(npm show ${{ matrix.package_name }} version || echo "0.0.0") echo "Published version (latest): $PUBLISHED_VERSION" if [ "$CURRENT_VERSION" = "$PUBLISHED_VERSION" ]; then echo "Version is unchanged." echo "version_changed=false" >> $GITHUB_OUTPUT else echo "Version has changed." echo "version_changed=true" >> $GITHUB_OUTPUT fi - name: Build the package if: steps.version_check.outputs.version_changed == 'true' || github.event_name == 'pull_request' run: pnpm nx build ${{ matrix.package_name }} - name: Configure .npmrc if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' run: | echo "@tryghost:registry=https://registry.npmjs.org/" >> ~/.npmrc # TODO: Check we can remove this once we update Node to v24 - name: Install v11 of NPM # We need this to install packages via OIDC. if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' run: npm install -g npm@11 - name: Publish to npm if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' working-directory: ${{ matrix.package_path }} run: | npm publish --access public - name: Replace version placeholders in cdn-paths id: cdn_paths if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' run: | cdn_paths="${{ matrix.cdn_paths }}" echo "cdn_paths<> $GITHUB_OUTPUT echo "$cdn_paths" | sed -e 's/CURRENT_MINOR/${{ steps.version_check.outputs.current_minor }}/g' -e 's/CURRENT_MAJOR/${{ steps.version_check.outputs.current_major }}/g' >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Print cdn_paths if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' run: echo "${{ steps.cdn_paths.outputs.cdn_paths }}" - name: Wait before purging jsDelivr cache if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' && matrix.package_name == '@tryghost/activitypub' run: | echo "Purging jsDelivr cache immediately after publishing a new version on NPM is unreliable. Waiting 1 minute before purging cache..." sleep 60 - name: Purge jsDelivr cache if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' uses: gacts/purge-jsdelivr-cache@8d92aea944f1a3e8ad70505379e1a8ac72d56b73 # v1 with: url: ${{ steps.cdn_paths.outputs.cdn_paths }} deploy_tinybird: name: Deploy Tinybird runs-on: ubuntu-latest needs: [ job_setup, job_tinybird-tests ] if: always() && github.repository == 'TryGhost/Ghost' && github.event_name == 'push' && needs.job_setup.outputs.changed_tinybird_datafiles == 'true' && needs.job_setup.result == 'success' && needs.job_tinybird-tests.result == 'success' && needs.job_setup.outputs.is_main == 'true' steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Trigger and watch traffic analytics infra Tinybird workflow if: github.repository == 'TryGhost/Ghost' env: GH_TOKEN: ${{ secrets.TRAFFIC_ANALYTICS_GITHUB_TOKEN }} uses: ./.github/actions/dispatch-workflow with: repo: TryGhost/traffic-analytics-infra workflow: tinybird.yml branch: main dispatch-inputs: >- { "ghost_ref": "${{ github.sha }}", "caller_run_id": "${{ github.run_id }}", "run_local_tests": false, "deploy_staging": true, "deploy_production": true } # --------------------------------------------------------------------------- # # Trigger Pro CD — dispatch to Ghost-Moya cd.yml (runs on main + PRs) # --------------------------------------------------------------------------- # trigger_cd: needs: [job_setup, job_build_artifacts] name: Trigger Pro CD runs-on: ubuntu-latest if: | always() && github.repository == 'TryGhost/Ghost' && needs.job_setup.result == 'success' && needs.job_build_artifacts.result == 'success' && needs.job_build_artifacts.outputs.use-artifact != 'true' steps: - name: Determine dispatch parameters id: params run: | if [ "${{ needs.job_setup.outputs.is_main }}" = "true" ]; then echo "pr_number=" >> $GITHUB_OUTPUT echo "deploy=" >> $GITHUB_OUTPUT elif [ "${{ needs.job_setup.outputs.is_tag }}" = "true" ]; then echo "pr_number=" >> $GITHUB_OUTPUT echo "deploy=" >> $GITHUB_OUTPUT elif [ "${{ github.event_name }}" = "pull_request" ]; then echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT # DISABLED: deploy-to-staging label detection is disabled. # The label workflow has fundamental problems — admin deploys are global # (not per-site) and main merges overwrite the deployment immediately. # See deploy-to-staging.yml for details. echo "deploy=" >> $GITHUB_OUTPUT else echo "skip=true" >> $GITHUB_OUTPUT exit 0 fi echo "skip=false" >> $GITHUB_OUTPUT - name: Dispatch to Ghost-Moya cd.yml if: steps.params.outputs.skip != 'true' uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 with: token: ${{ secrets.CANARY_DOCKER_BUILD }} repository: TryGhost/Ghost-Moya event-type: ghost-artifacts-ready client-payload: >- { "ref": "${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || github.sha }}", "source_repo": "${{ github.repository }}", "pr_number": "${{ steps.params.outputs.pr_number }}", "deploy": "${{ steps.params.outputs.deploy }}", "admin_artifact_id": "${{ needs.job_build_artifacts.outputs.admin-artifact-id }}", "admin_artifact_run_id": "${{ github.run_id }}" } # --------------------------------------------------------------------------- # # Publish Ghost npm package — runs on version tags only (OIDC, no stored token) # --------------------------------------------------------------------------- # publish_ghost: needs: [job_setup, job_build_artifacts] name: Publish Ghost to npm runs-on: ubuntu-latest if: | startsWith(github.ref, 'refs/tags/v') && github.repository == 'TryGhost/Ghost' && needs.job_build_artifacts.result == 'success' environment: npm-release permissions: id-token: write steps: - name: Download npm tarball uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ghost-npm-tarball - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: ${{ env.NODE_VERSION }} package-manager-cache: false # TODO: Remove once Node v24 ships with npm >= 11 - name: Install npm v11 (required for OIDC publishing) run: npm install -g npm@11 - name: Verify tarball contents run: | echo "Tarball contents:" tar -tzf ghost-*.tgz | head -20 tar -tzf ghost-*.tgz | grep -q 'package/.npmrc' || { echo "::error::.npmrc not found in tarball"; exit 1; } tar -tzf ghost-*.tgz | grep -q 'package/pnpm-lock.yaml' || { echo "::error::pnpm-lock.yaml not found in tarball"; exit 1; } tar -xOf ghost-*.tgz package/package.json | jq -e '.packageManager' >/dev/null || { echo "::error::packageManager not found in packaged package.json"; exit 1; } - name: Publish to npm run: npm publish ghost-*.tgz --access public - uses: tryghost/actions/actions/slack-build@20b5ae5f266e86f7b5f0815d92731d6388b8ce46 # main if: failure() with: status: ${{ job.status }} env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} # --------------------------------------------------------------------------- # # Create GitHub Release — runs after successful npm publish # --------------------------------------------------------------------------- # create_github_release: needs: [publish_ghost] name: Create GitHub Release runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write env: GH_TOKEN: ${{ secrets.CANARY_DOCKER_BUILD }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Resolve previous tag id: prev_tag run: | CURRENT_TAG="${GITHUB_REF_NAME}" # Find the tag immediately before this one (excluding pre-releases) PREV_TAG=$(git tag --list 'v[0-9]*' --sort=-version:refname | grep -v '-' | grep -v "^${CURRENT_TAG}$" | head -n 1) if [ -z "$PREV_TAG" ]; then echo "::warning::No previous stable tag found — release notes will use fallback message" fi echo "tag=${PREV_TAG}" >> "$GITHUB_OUTPUT" echo "Previous tag: ${PREV_TAG:-}" - name: Generate release notes id: notes run: | PREV_TAG="${{ steps.prev_tag.outputs.tag }}" if [ -n "$PREV_TAG" ]; then node scripts/lib/release-notes.js "$PREV_TAG" "${GITHUB_REF_NAME}" > /tmp/release-notes.md else echo "This release contains fixes for minor bugs and issues reported by Ghost users." > /tmp/release-notes.md fi cat /tmp/release-notes.md - name: Create GitHub Release run: | gh release create "${GITHUB_REF_NAME}" \ --title "${GITHUB_REF_NAME}" \ --notes-file /tmp/release-notes.md - name: Notify Slack if: always() && steps.notes.outcome == 'success' run: | VERSION="${GITHUB_REF_NAME}" RELEASE_URL="https://github.com/TryGhost/Ghost/releases/tag/${VERSION}" CHANGELOG=$(cat /tmp/release-notes.md | head -c 3000) # Build Slack payload — use --rawfile so newlines in release notes are preserved PAYLOAD=$(jq -n \ --arg header ":ghost: Ghost ${VERSION} is loose! - ${RELEASE_URL}" \ --rawfile notes /tmp/release-notes.md \ '{text: ($header + "\n\n" + $notes)}') curl -sf -X POST \ -H 'Content-type: application/json' \ --data "${PAYLOAD}" \ "${{ secrets.RELEASE_NOTIFICATION_URL }}" || echo "Slack notification failed (non-fatal)"