🎨 Added syntax-highlighted diff view to theme editor review modal #42849
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | |
| - 'v[0-9]*' # Version tags trigger release publishing (npm, GitHub Release, Docker) | |
| 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: | |
| # Tag pushes get a unique uninterruptible group so a release CI run always | |
| # finishes; branch / PR pushes keep cancel-on-newer behavior. | |
| group: ${{ startsWith(github.ref, 'refs/tags/') && format('release-{0}', github.ref) || github.head_ref || github.ref }} | |
| cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} | |
| 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 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' | |
| ci: | |
| - '.github/workflows/**' | |
| - '.github/actions/**' | |
| - '.github/scripts/**' | |
| core: | |
| - *shared | |
| - 'ghost/**' | |
| - '!ghost/admin/**' | |
| - '!ghost/core/core/server/data/tinybird/**' | |
| 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/**' | |
| comments-ui: | |
| - *shared | |
| - 'apps/comments-ui/**' | |
| signup-form: | |
| - *shared | |
| - 'apps/signup-form/**' | |
| 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' | |
| - '!.devcontainer/**' | |
| - '!.vscode/**' | |
| - 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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 | |
| id: affected | |
| run: | | |
| # if the ci files have changed or we're in a tag, ensure we don't just look at affected | |
| # projects and we run the necessary jobs on all projects | |
| AFFECTED_ARG="--affected" | |
| if [[ "${{ steps.changed.outputs.ci }}" == 'true' || "${{ env.IS_TAG }}" == 'true' ]]; then | |
| AFFECTED_ARG="" | |
| fi | |
| AFFECTED_PROJECTS=$(pnpm -s nx show projects ${AFFECTED_ARG} --json | tr -d '\n') | |
| echo "affected_projects=$AFFECTED_PROJECTS" >> "$GITHUB_OUTPUT" | |
| # string list for use in run-many commands | |
| AFFECTED_PROJECTS_STR=$(pnpm -s nx show projects ${AFFECTED_ARG} --sep=, | tr -d '\n') | |
| echo "affected_projects_str=$AFFECTED_PROJECTS_STR" >> "$GITHUB_OUTPUT" | |
| # "i18n" tag = packages whose source is scanned by @tryghost/i18n's | |
| # translate:* scripts (not packages that merely import @tryghost/i18n). | |
| I18N_PROJECTS=$(pnpm -s nx show projects ${AFFECTED_ARG} --projects 'tag:i18n' --sep=, | tr -d '\n') | |
| echo "affected_i18n_projects=${I18N_PROJECTS}" >> "$GITHUB_OUTPUT" | |
| outputs: | |
| affected_projects: ${{ steps.affected.outputs.affected_projects }} | |
| affected_projects_str: ${{ steps.affected.outputs.affected_projects_str }} | |
| changed_i18n_apps: ${{ steps.affected.outputs.affected_i18n_projects != '' }} | |
| changed_core: ${{ steps.changed.outputs.core }} | |
| changed_admin_x_settings: ${{ steps.changed.outputs.admin-x-settings }} | |
| changed_activitypub: ${{ steps.changed.outputs.activitypub }} | |
| changed_comments_ui: ${{ steps.changed.outputs.comments-ui }} | |
| changed_signup_form: ${{ steps.changed.outputs.signup-form }} | |
| changed_tinybird: ${{ steps.changed.outputs.tinybird }} | |
| changed_tinybird_datafiles: ${{ steps.changed.outputs.tinybird-datafiles }} | |
| changed_any_code: ${{ steps.changed.outputs.any-code }} | |
| 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_migration_integrity_check: | |
| name: Check migration integrity | |
| 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 PR base branch | |
| run: git fetch --no-tags origin "${{ github.event.pull_request.base.ref }}" | |
| - name: Check migration integrity | |
| env: | |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| PR_COMPARE_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: node .github/scripts/check-migration-integrity.js | |
| job_lint: | |
| runs-on: ubuntu-latest | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.affected_projects_str != '' | |
| name: Lint | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 1000 | |
| - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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 projects | |
| run: pnpm nx run-many -t lint -p "${{ needs.job_setup.outputs.affected_projects_str }}" | |
| 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_i18n_apps == 'true' | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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' | |
| || contains(fromJSON(needs.job_setup.outputs.affected_projects), 'ghost-admin') | |
| 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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.affected_projects_str != '' | |
| 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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 | |
| 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 | |
| logging__level: fatal | |
| - 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@sha256:352c1fdadc91926edda08f45aeb3f27f37194c2f14101229c0523a11195c96e3 | |
| 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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: Start MinIO for integration tests | |
| run: | | |
| docker run -d --rm --name minio \ | |
| -p 9000:9000 \ | |
| -e MINIO_ROOT_USER=minio-user \ | |
| -e MINIO_ROOT_PASSWORD=minio-pass \ | |
| minio/minio:RELEASE.2024-12-13T22-19-12Z@sha256:149fdd73108553247ceee85fc65466f51034bd6e145d6e0c0e415167f5f1274f \ | |
| server /data | |
| for i in $(seq 1 30); do | |
| if curl -sf http://127.0.0.1:9000/minio/health/ready; then | |
| echo "MinIO ready after ${i}s" | |
| exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "MinIO did not become ready in time" >&2 | |
| docker logs minio | |
| exit 1 | |
| - 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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:47b0cd32999d7b55a8886cd86c135e813c67357991dad14f645f041126df8604 | |
| 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, job_build_artifacts] | |
| if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Install Ghost-CLI | |
| run: npm install -g ghost-cli@latest | |
| # Test against the same tarball that npm-publish ships, not a parallel rebuild. | |
| - name: Download npm tarball | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: ghost-npm-tarball | |
| - run: mv ghost-*.tgz ghost.tgz | |
| - name: Verify packaged package.json | |
| run: tar -xOf ghost.tgz package/package.json | jq -e '.packageManager' >/dev/null | |
| - 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.tgz" | |
| URL=$(ghost config get url -d "$DIR" --no-prompt --no-color | tail -n1) | |
| curl --retry 10 --retry-connrefused --retry-delay 3 -fsSI "$URL" | |
| ghost stop -d "$DIR" | |
| - name: Latest Release | |
| run: | | |
| DIR=$(mktemp -d) | |
| ghost install local -d "$DIR" | |
| 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" | |
| ghost stop -d "$DIR" | |
| - 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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: Build standalone distribution | |
| run: pnpm --filter ghost archive | |
| - name: Upload npm tarball | |
| 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: Setup Docker Registry Mirrors | |
| uses: ./.github/actions/setup-docker-registry-mirrors | |
| - 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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: Setup Docker Registry Mirrors | |
| uses: ./.github/actions/setup-docker-registry-mirrors | |
| - 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: Setup Docker Registry Mirrors | |
| uses: ./.github/actions/setup-docker-registry-mirrors | |
| - 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 | |
| - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 | |
| - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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_migration_integrity_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, | |
| build_packages, | |
| 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 | |
| # Build verification for @tryghost/* public apps. | |
| # Runs on PRs and pushes. Intentionally has no `id-token: write` — PR-controlled | |
| # code (nx build) must never execute in a job that can mint an npm OIDC token. | |
| # The privileged publish flow lives in publish_packages below, gated to main pushes. | |
| build_packages: | |
| needs: [ | |
| job_setup, | |
| job_lint, | |
| job_unit-tests | |
| ] | |
| name: Build ${{ 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: | |
| contents: read | |
| strategy: | |
| matrix: | |
| include: | |
| - package_name: '@tryghost/activitypub' | |
| package_path: 'apps/activitypub' | |
| - package_name: '@tryghost/portal' | |
| package_path: 'apps/portal' | |
| - package_name: '@tryghost/sodo-search' | |
| package_path: 'apps/sodo-search' | |
| - package_name: '@tryghost/comments-ui' | |
| package_path: 'apps/comments-ui' | |
| - package_name: '@tryghost/signup-form' | |
| package_path: 'apps/signup-form' | |
| - package_name: '@tryghost/announcement-bar' | |
| package_path: 'apps/announcement-bar' | |
| 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Build the package | |
| run: pnpm nx build ${{ matrix.package_name }} | |
| # Publishes @tryghost/* public apps to npm via OIDC trusted publishing. | |
| # Runs only on push-to-main — never on pull_request — so the `id-token: write` | |
| # permission is never exposed to PR-controlled code (ref: ONC-1677). | |
| publish_packages: | |
| needs: [ | |
| job_setup, | |
| job_lint, | |
| job_unit-tests, | |
| build_packages | |
| ] | |
| name: Publish ${{ matrix.package_name }} | |
| runs-on: ubuntu-latest | |
| if: | | |
| github.event_name != 'pull_request' | |
| && github.repository == 'TryGhost/Ghost' | |
| && needs.job_setup.outputs.is_main == 'true' | |
| && needs.job_setup.result == 'success' | |
| && needs.job_lint.result == 'success' | |
| && needs.job_unit-tests.result == 'success' | |
| && needs.build_packages.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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Check if version changed | |
| 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' | |
| run: pnpm nx build ${{ matrix.package_name }} | |
| - name: Configure .npmrc | |
| if: 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: steps.version_check.outputs.version_changed == 'true' | |
| run: npm install -g npm@11 | |
| - name: Publish to npm | |
| if: steps.version_check.outputs.version_changed == 'true' | |
| working-directory: ${{ matrix.package_path }} | |
| run: | | |
| pnpm publish --access public --no-git-checks | |
| - name: Replace version placeholders in cdn-paths | |
| id: cdn_paths | |
| if: steps.version_check.outputs.version_changed == 'true' | |
| run: | | |
| cdn_paths="${{ matrix.cdn_paths }}" | |
| echo "cdn_paths<<EOF" >> $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: steps.version_check.outputs.version_changed == 'true' | |
| run: echo "${{ steps.cdn_paths.outputs.cdn_paths }}" | |
| - name: Wait before purging jsDelivr cache | |
| if: 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: 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_ghost-cli] | |
| name: Publish Ghost to npm | |
| runs-on: ubuntu-latest | |
| if: | | |
| startsWith(github.ref, 'refs/tags/v') | |
| && github.repository == 'TryGhost/Ghost' | |
| 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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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: tar -xOf ghost-*.tgz package/package.json | jq -e '.packageManager' >/dev/null | |
| - 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:-<none>}" | |
| - 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)" |