🐛 Fixed periodic host limits in React Admin settings #54432
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.23.1 | |
| # 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 | |
| # Nx's native (Rust/NAPI) command runner intermittently segfaults on the CI | |
| # runners when it forks a task through its pseudo-terminal — the process is | |
| # killed with SIGSEGV before the target script even starts. It's an upstream | |
| # Nx instability (see nrwl/nx#27917, nrwl/nx#2956), not a Ghost bug, and it | |
| # can hit any job that runs `nx run`/`nx run-many`, so the switch to the | |
| # legacy child_process runner is set globally rather than per-job. The legacy | |
| # runner was Nx's default before the native one existed; parallelism is | |
| # unaffected and output/colour is preserved (FORCE_COLOR is set above), so | |
| # there's no meaningful cost in CI. | |
| NX_NATIVE_COMMAND_RUNNER: "false" | |
| 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/') }} | |
| # Default least-privilege token for all jobs; jobs that need more declare | |
| # their own permissions block, which overrides this default. | |
| permissions: | |
| contents: read | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| 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) }}" | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - name: Set up Node | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile --ignore-scripts | |
| # Replaced nrwl/nx-set-shas, which verified each candidate commit over the | |
| # API and hid the errors — see scripts/nx-set-shas.js. | |
| - name: Set SHAs for Nx Commands | |
| if: env.IS_TAG != 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} | |
| # Canonical main is the one branch where too narrow a base means | |
| # untested commits land, so there a lookup that comes up empty fails | |
| # the run rather than falling back to the previous commit. | |
| ON_MISSING: ${{ (env.IS_MAIN == 'true' && github.repository == 'TryGhost/Ghost') && 'error' || 'previous-commit' }} | |
| run: node scripts/nx-set-shas.js --branch "$BRANCH" --head "$HEAD_COMMIT" --on-missing "$ON_MISSING" | |
| - 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/**' | |
| # The @internal/scripts workspace sits outside .github but is | |
| # invoked by the workflows below, so it needs the same treatment | |
| # the '.github/**' glob used to give it when it lived there. | |
| - 'scripts/**' | |
| # Renovate config / workflow files have no Ghost | |
| # runtime/test impact. Anchored as &renovate_only and | |
| # referenced from every filter below so a PR touching | |
| # only these files skips all downstream jobs; the | |
| # rollup gate passes on skipped deps. | |
| - &renovate_only | |
| - '!.github/renovate.json5' | |
| - '!.github/renovate-bot.cjs' | |
| - '!.github/workflows/renovate.yml' | |
| - '.npmrc' | |
| - 'nx.json' | |
| - 'package.json' | |
| - 'pnpm-lock.yaml' | |
| - 'pnpm-workspace.yaml' | |
| ci: | |
| - '.github/workflows/**' | |
| - *renovate_only | |
| - '.github/actions/**' | |
| - '.github/scripts/**' | |
| - 'scripts/**' | |
| docs: | |
| - '**/*.md' | |
| - '**/*.mdx' | |
| - '.agents/**' | |
| - '.claude/**' | |
| - '.github/workflows/ci.yml' | |
| - 'package.json' | |
| - 'scripts/check-agent-skill-links.js' | |
| - 'scripts/test/check-agent-skill-links.test.js' | |
| - 'scripts/check-agent-guidance.js' | |
| - 'scripts/test/check-agent-guidance.test.js' | |
| package-standards: | |
| - 'packages/**' | |
| - 'package.json' | |
| - 'pnpm-workspace.yaml' | |
| - 'scripts/check-internal-packages.js' | |
| - 'scripts/create-package.js' | |
| - 'scripts/lib/constants.js' | |
| - 'scripts/lib/package-template.js' | |
| - 'scripts/test/check-internal-packages.test.js' | |
| - '.github/workflows/ci.yml' | |
| core: | |
| - *shared | |
| - '!.github/CODEOWNERS' | |
| - 'ghost/**' | |
| - '!ghost/core/core/server/data/tinybird/**' | |
| # Unit tests + vitest config are exercised only by job_unit-tests; | |
| # they never affect the acceptance / legacy / ghost-cli jobs. | |
| - '!ghost/core/test/unit/**' | |
| - '!ghost/core/vitest.config.ts' | |
| - '!ghost/core/test/utils/vitest-setup.ts' | |
| # Koenig packages that are runtime deps of ghost/core — changes | |
| # here alter server rendering behaviour, so the acceptance / | |
| # legacy suites must run. Editor-only packages are excluded. | |
| - 'koenig/**' | |
| - '!koenig/koenig-lexical/**' | |
| - '!koenig/kg-unsplash-selector/**' | |
| - '!koenig/kg-simplemde/**' | |
| - '!koenig/*/test/**' | |
| # Documentation does not affect Ghost runtime behaviour, even | |
| # when it lives inside a project root. Keep this after every | |
| # positive pattern so micromatch cannot add docs files back. | |
| - '!**/*.md' | |
| - '!**/*.mdx' | |
| unit-test-globals: | |
| - 'vitest.config.mjs' | |
| core-unit-test-globals: | |
| - 'ghost/core/vitest.config.ts' | |
| - 'ghost/core/test/utils/vitest-*.ts' | |
| any-code: | |
| - '!**/*.md' | |
| - '!**/*.mdx' | |
| - '!.devcontainer/**' | |
| - '!.vscode/**' | |
| - *renovate_only | |
| # Drives the run_e2e output: matches any changed file that could | |
| # affect a running Ghost instance. Test files, test config and | |
| # docs are excluded — a change confined to them cannot alter | |
| # product behaviour, so the build + E2E lane is skipped. | |
| # ghost/core test paths are listed today; app test paths can be | |
| # added here as their conventions are confirmed. | |
| e2e: | |
| - '!**/*.md' | |
| - '!**/*.mdx' | |
| - '!.devcontainer/**' | |
| - '!.vscode/**' | |
| - '!ghost/core/test/**' | |
| - '!ghost/core/vitest.config.ts' | |
| - '!koenig/*/test/**' | |
| - *renovate_only | |
| # The Analytics E2E shards normally pull a prebuilt tb-cli image | |
| # from GHCR, which is only republished from main. Without this the | |
| # PR that changes the Dockerfile is the one PR that never exercises | |
| # it — the break lands on main and surfaces in someone else's PR. | |
| tb-cli: | |
| - 'docker/tb-cli/**' | |
| - name: Define Node test matrix | |
| id: node_matrix | |
| run: | | |
| echo 'matrix=["22.23.1"]' >> $GITHUB_OUTPUT | |
| - name: Start Nx Cloud CI run | |
| run: pnpm nx start-ci-run | |
| - name: Determine Affected Projects | |
| id: affected | |
| run: | | |
| # Nx treats README files as project inputs. Avoid populating code-test | |
| # matrices when every changed file is documentation. | |
| if [[ "${{ env.IS_TAG }}" != 'true' && "${{ steps.changed.outputs.any-code }}" != 'true' ]]; then | |
| echo 'affected_projects=[]' >> "$GITHUB_OUTPUT" | |
| echo 'affected_projects_str=' >> "$GITHUB_OUTPUT" | |
| echo 'unit_test_projects_str=' >> "$GITHUB_OUTPUT" | |
| echo 'affected_i18n_projects=' >> "$GITHUB_OUTPUT" | |
| echo 'affected_playwright_projects=[]' >> "$GITHUB_OUTPUT" | |
| echo 'publish_public_apps_matrix=[]' >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # 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) | |
| 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" | |
| UNIT_TEST_AFFECTED_ARG="$AFFECTED_ARG" | |
| if [[ "${{ steps.changed.outputs.unit-test-globals }}" == 'true' ]]; then | |
| UNIT_TEST_AFFECTED_ARG="" | |
| fi | |
| UNIT_TEST_PROJECTS_STR=$(pnpm -s nx show projects ${UNIT_TEST_AFFECTED_ARG} --withTarget test:unit --sep=, | tr -d '\n') | |
| if [[ "${{ steps.changed.outputs.core-unit-test-globals }}" == 'true' ]]; then | |
| UNIT_TEST_PROJECTS_STR=$(printf '%s\n%s\n' "$UNIT_TEST_PROJECTS_STR" "ghost" | awk 'NF && !seen[$0]++' | paste -sd, -) | |
| fi | |
| echo "unit_test_projects_str=$UNIT_TEST_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" | |
| # "playwright" tag = projects whose test:acceptance suite runs in | |
| # job_apps_acceptance-tests. Tag-based rather than directory-based so | |
| # the matrix isn't coupled to the workspace layout (apps/*, koenig/*, ...). | |
| PLAYWRIGHT_PROJECTS=$(pnpm -s nx show projects ${AFFECTED_ARG} \ | |
| --withTarget test:acceptance \ | |
| --projects 'tag:playwright' \ | |
| --json) | |
| echo "affected_playwright_projects=$PLAYWRIGHT_PROJECTS" >> "$GITHUB_OUTPUT" | |
| # Build the publish matrix for affected public apps. Matrix context | |
| # isn't available in a job-level `if:`, so the publish job can't filter | |
| # itself per-app — instead we emit the matrix here and it skips when | |
| # the result is `[]`. The publish job's own is_main / event gates keep | |
| # tags and PRs from publishing regardless of what this lists. | |
| PUBLISH_PUBLIC_APPS_MATRIX=$(node scripts/build-public-apps-matrix.js "$AFFECTED_PROJECTS") | |
| echo "publish_public_apps_matrix=$PUBLISH_PUBLIC_APPS_MATRIX" >> "$GITHUB_OUTPUT" | |
| outputs: | |
| affected_projects: ${{ steps.affected.outputs.affected_projects }} | |
| affected_projects_str: ${{ steps.affected.outputs.affected_projects_str }} | |
| unit_test_projects_str: ${{ steps.affected.outputs.unit_test_projects_str }} | |
| affected_playwright_projects: ${{ steps.affected.outputs.affected_playwright_projects }} | |
| publish_public_apps_matrix: ${{ steps.affected.outputs.publish_public_apps_matrix }} | |
| changed_i18n_apps: ${{ steps.affected.outputs.affected_i18n_projects != '' }} | |
| changed_core: ${{ steps.changed.outputs.core }} | |
| changed_any_code: ${{ steps.changed.outputs.any-code }} | |
| changed_docs: ${{ steps.changed.outputs.docs }} | |
| changed_package_standards: ${{ steps.changed.outputs.package-standards }} | |
| changed_tb_cli: ${{ steps.changed.outputs.tb-cli }} | |
| # Single gate for the build + browser-E2E lane. True for tags, or when a | |
| # changed file could affect a running Ghost instance (see the `e2e` path | |
| # filter above). A test-only / docs-only change keeps this false. | |
| run_e2e: ${{ env.IS_TAG == 'true' || steps.changed.outputs.e2e == 'true' }} | |
| # Coverage instrumentation (c8 for core, ember-cli-code-coverage for | |
| # admin) and the Codecov upload only happen on the canonical repo. Forks | |
| # — including TryGhost's private ones — don't report to Ghost's Codecov | |
| # project, so instrumenting their test runs just slows CI down to produce | |
| # a report nothing consumes. | |
| coverage_enabled: ${{ github.repository == 'TryGhost/Ghost' }} | |
| 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 }} | |
| has_perf_tests_label: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'perf-tests') }} | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| fetch-depth: 0 | |
| - name: Fetch main branch | |
| run: git fetch --no-tags origin main | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| # The script's only runtime dep is semver, so install @internal/scripts | |
| # alone rather than the whole workspace — ~0.5s and a single package. | |
| - name: Install scripts dependencies | |
| run: pnpm install --frozen-lockfile --filter @internal/scripts --prod --ignore-scripts | |
| - 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 scripts/check-app-version-bump.js | |
| # Fail the PR if a publishable package changed (source or a catalog entry | |
| # it consumes) but the branch adds no changeset covering it. Prototype of | |
| # pnpm's forthcoming `pnpm change check`. | |
| - name: Check for missing changesets | |
| env: | |
| PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| PR_COMPARE_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: node scripts/change-check.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| 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 scripts/check-migration-integrity.cjs | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| fetch-depth: 1000 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 | |
| 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 }} | |
| - name: Lint boundaries | |
| run: pnpm nx run ghost-monorepo:lint:boundaries | |
| - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # 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_lint_docs: | |
| name: Lint docs | |
| runs-on: ubuntu-slim | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.changed_docs == 'true' | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Lint documentation guidance | |
| run: | | |
| node --test scripts/test/check-agent-guidance.test.js | |
| node scripts/check-agent-skill-links.js | |
| node scripts/check-agent-guidance.js | |
| job_lint_packages: | |
| name: Lint packages | |
| runs-on: ubuntu-slim | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.changed_package_standards == 'true' | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Check internal package golden path | |
| run: node scripts/check-internal-packages.js | |
| - name: Test internal package checker | |
| run: node --test scripts/test/check-internal-packages.test.js | |
| 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile --filter @tryghost/i18n... --ignore-scripts | |
| - name: Run i18n tests | |
| run: pnpm --filter @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: ${{ needs.job_setup.outputs.coverage_enabled }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| 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 | |
| if: needs.job_setup.outputs.coverage_enabled == 'true' | |
| run: pnpm ember coverage-merge | |
| working-directory: apps/ember-admin | |
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| if: needs.job_setup.outputs.coverage_enabled == 'true' | |
| with: | |
| name: admin-coverage | |
| path: apps/*/coverage/cobertura-coverage.xml | |
| - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main | |
| if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| with: | |
| status: ${{ job.status }} | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| # Measures Ghost's boot time with hyperfine and appends the result to the | |
| # long-running series charted at https://tryghost.github.io/Ghost-Benchmarks/ | |
| # (data lives on the gh-pages branch of TryGhost/Ghost-Benchmarks). | |
| # | |
| # Changing the runner label or the hyperfine version makes new results | |
| # incomparable with the existing history - treat both as pinned. | |
| job_perf-tests: | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| needs: [job_setup] | |
| if: (needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_development == 'true') || needs.job_setup.outputs.has_perf_tests_label == 'true' | |
| name: Performance tests | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| # Boot activates the default theme, which is a submodule | |
| submodules: true | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # --force for the same reason as job_unit-tests: better-sqlite3 is an | |
| # optionalDependency and boot uses it for the development database. | |
| run: pnpm install --frozen-lockfile --force | |
| - name: Install hyperfine | |
| uses: ./.github/actions/install-hyperfine | |
| - name: Build TS code | |
| run: pnpm nx run-many -t build:tsc | |
| - name: Build assets | |
| run: pnpm --filter ghost run build:assets | |
| - name: Run hyperfine on boot | |
| working-directory: ghost/core | |
| run: hyperfine --show-output --warmup 3 'GHOST_CI_SHUTDOWN_AFTER_BOOT=1 node index.js' --export-json boot-perf.json | |
| - name: Report result | |
| uses: ./.github/actions/report-boot-benchmark | |
| with: | |
| results-file: ghost/core/boot-perf.json | |
| series: Benchmark | |
| metric: Boot time | |
| title: Boot time (dev tree) | |
| github-token: ${{ secrets.CANARY_DOCKER_BUILD }} | |
| # Boot time of the production image, as a series separate from job_perf-tests: | |
| # that one tracks the code, this one tracks what ships (NODE_ENV=production, no | |
| # devDependencies, jemalloc, baked compile cache). Never merge them - a base image | |
| # bump would land in the code series as a regression with no Ghost commit behind it. | |
| job_perf-tests-image: | |
| name: Performance tests (production image) | |
| runs-on: blacksmith-2vcpu-ubuntu-2404 | |
| needs: [job_setup, job_docker] | |
| # Registry path only: benchmarks are a canonical-repo series, so this pulls the | |
| # core image from GHCR rather than loading the artifact-path tarball. | |
| if: | | |
| needs.job_docker.result == 'success' && | |
| needs.job_docker.outputs.use-artifact == 'false' && | |
| ((needs.job_setup.outputs.changed_core == 'true' && needs.job_setup.outputs.is_development == 'true') || needs.job_setup.outputs.has_perf_tests_label == 'true') | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| # Only for .github/actions/load-docker-image; nothing is built from source here. | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| - name: Install hyperfine | |
| uses: ./.github/actions/install-hyperfine | |
| - name: Load core image | |
| uses: ./.github/actions/load-docker-image | |
| id: load | |
| with: | |
| use-artifact: 'false' | |
| image-tags: ${{ needs.job_docker.outputs.image-core-tags }} | |
| - name: Start MySQL | |
| # Own network rather than a service container, so Ghost can reach it by name. | |
| # Pinned by digest: a MySQL bump moves the measurement, so it should be a | |
| # deliberate baseline change rather than drift in the series. | |
| run: | | |
| docker network create ghost-perf | |
| docker run -d --name mysql --network ghost-perf \ | |
| --tmpfs /var/lib/mysql \ | |
| -e MYSQL_DATABASE=ghost \ | |
| -e MYSQL_ROOT_PASSWORD=root \ | |
| --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot" \ | |
| --health-interval=2s \ | |
| --health-timeout=5s \ | |
| --health-retries=60 \ | |
| mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b | |
| for _ in $(seq 1 60); do | |
| if [ "$(docker inspect -f '{{.State.Health.Status}}' mysql)" = "healthy" ]; then | |
| exit 0 | |
| fi | |
| sleep 2 | |
| done | |
| echo "MySQL did not become healthy" | |
| docker logs mysql | |
| exit 1 | |
| - name: Start Ghost container | |
| # Kept alive with `sleep` so each measured run is a `docker exec`; timing | |
| # `docker run` would put container create/start inside the measurement. | |
| env: | |
| IMAGE_TAG: ${{ steps.load.outputs.image-tag }} | |
| run: | | |
| docker run -d --name ghost --network ghost-perf --entrypoint sleep \ | |
| -e GHOST_CI_SHUTDOWN_AFTER_BOOT=1 \ | |
| -e database__client=mysql \ | |
| -e database__connection__host=mysql \ | |
| -e database__connection__user=root \ | |
| -e database__connection__password=root \ | |
| -e database__connection__database=ghost \ | |
| "$IMAGE_TAG" infinity | |
| - name: Run migrations (discarded first boot) | |
| # A fresh database costs ~6.5s of migrations vs ~2.3s steady state. | |
| run: docker exec ghost node index.js | |
| - name: Run hyperfine on boot | |
| run: hyperfine --show-output --warmup 3 'docker exec ghost node index.js' --export-json boot-perf-image.json | |
| - name: Report result | |
| uses: ./.github/actions/report-boot-benchmark | |
| with: | |
| results-file: boot-perf-image.json | |
| # Own subdirectory in the benchmarks repo (like dx/), so this series gets its | |
| # own chart and data file and the root series is left untouched. | |
| data-dir: docker | |
| series: Production image | |
| metric: Boot time | |
| title: Boot time (production image) | |
| github-token: ${{ secrets.CANARY_DOCKER_BUILD }} | |
| job_unit-tests: | |
| runs-on: ubuntu-latest | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.unit_test_projects_str != '' | |
| strategy: | |
| matrix: | |
| node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} | |
| name: Unit tests (Node ${{ matrix.node }}) | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| fetch-depth: 1000 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ matrix.node }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # better-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 better-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 | |
| # ghost/core's unit tests run on vitest (see ghost/core/vitest.config.ts); | |
| # other packages run their own test:unit target. | |
| # | |
| # Retried up to 3 attempts: ghost/core's vitest run intermittently | |
| # crashes a worker on loaded CI runners — an abnormal process exit, | |
| # not a reported test failure. nx caches the projects that already | |
| # passed, so a retry only re-runs the crashed one. Interim stopgap | |
| # until the vitest worker/teardown issue is fixed. | |
| # | |
| # Each attempt is bounded by `timeout`: a test process that finishes | |
| # its suites but never exits (lingering worker/handle) would otherwise | |
| # wedge the job until the 6h runner limit. Killing it turns the hang | |
| # into a retriable failure. A full cold (uncached) run takes ~10min. | |
| run: | | |
| for attempt in 1 2 3; do | |
| timeout 25m pnpm nx run-many -t test:unit -p "${{ needs.job_setup.outputs.unit_test_projects_str }}" && exit 0 | |
| if [ "${attempt}" -lt 3 ]; then | |
| echo "::warning::Unit tests attempt ${attempt} failed — retrying" | |
| else | |
| echo "::error::Unit tests failed after ${attempt} attempts — no more retries" | |
| fi | |
| done | |
| exit 1 | |
| 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: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # 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 | |
| # Poll the healthcheck every 2s instead of 10s — same max wait window | |
| # (120s = 60 × 2s), but ready-state is discovered ~8s earlier on a | |
| # typical boot. Same change on the redis service below. | |
| options: >- | |
| --tmpfs /var/lib/mysql | |
| --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot" | |
| --health-interval=2s | |
| --health-timeout=5s | |
| --health-retries=60 | |
| redis: | |
| image: redis:7.4@sha256:e9b2e45ecd47fbb69b877cf8d045d5cccaaaed52524b6e098b4abe8212994f73 | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval=2s | |
| --health-timeout=5s | |
| --health-retries=60 | |
| 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: better-sqlite3 | |
| NODE_ENV: testing | |
| env: | |
| DB: ${{ matrix.env.DB }} | |
| NODE_ENV: ${{ matrix.env.NODE_ENV }} | |
| # The `test:ci:*` targets wrap the suites in c8. Only the sqlite leg is | |
| # instrumented (mysql covers the same code), and only on the canonical | |
| # repo — see job_setup's coverage_enabled output. | |
| COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled == 'true' && matrix.env.DB == 'better-sqlite3' }} | |
| name: Acceptance tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ matrix.node }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # better-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. The mysql leg | |
| # doesn't need better-sqlite3, so it skips --force and gets a fast cache | |
| # restore (matches the legacy-tests job below). | |
| run: | | |
| if [ "${{ matrix.env.DB }}" = "better-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" | |
| echo "database__connection__port=${{ job.services.mysql.ports['3306'] }}" | |
| echo "database__connection__password=root" | |
| } >> "$GITHUB_ENV" | |
| - name: E2E tests | |
| run: | | |
| if [ "$COVERAGE_ENABLED" = "true" ]; then | |
| pnpm nx run ghost:test:ci:e2e | |
| else | |
| pnpm nx run ghost:test:e2e | |
| fi | |
| - 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: | | |
| if [ "$COVERAGE_ENABLED" = "true" ]; then | |
| pnpm nx run ghost:test:ci:integration | |
| else | |
| pnpm nx run ghost:test:integration | |
| fi | |
| - 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: env.COVERAGE_ENABLED == 'true' && matrix.node == env.NODE_VERSION | |
| with: | |
| name: e2e-coverage | |
| path: | | |
| ghost/*/coverage-e2e/cobertura-coverage.xml | |
| ghost/*/coverage-integration/cobertura-coverage.xml | |
| - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # 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: better-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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| submodules: true | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ matrix.node }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # better-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 }}" = "better-sqlite3" ]; then | |
| pnpm install --frozen-lockfile --force | |
| else | |
| pnpm install --frozen-lockfile | |
| fi | |
| - name: Set env vars (SQLite) | |
| if: contains(matrix.env.DB, 'better-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 | |
| # Bounded so a vitest run that completes its suites but hangs in | |
| # teardown fails fast instead of wedging the job until the 6h runner | |
| # limit. A normal run takes ~2-3min; 10m leaves ample headroom. | |
| timeout-minutes: 10 | |
| run: pnpm nx run ghost:test: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@12da0671df2e249a65c467340262e6c4251d9565 # 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_apps_acceptance-tests: | |
| runs-on: ubuntu-latest | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.affected_playwright_projects != '[]' | |
| name: App Playwright Acceptance Tests | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| app: ${{ fromJSON(needs.job_setup.outputs.affected_playwright_projects) }} | |
| env: | |
| CI: true | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # Each matrix leg only Playwright-tests one app, so scope the install to that | |
| # app's dependency subgraph instead of the whole monorepo (which drags in | |
| # ghost/core, ghost-admin and unrelated apps). nx and the app's workspace deps | |
| # are still installed; the nx project name matches the package name. | |
| run: pnpm install --frozen-lockfile --filter ${{ matrix.app }}... | |
| - name: Setup Playwright | |
| uses: ./.github/actions/setup-playwright | |
| # Cache the msttcorefonts .ttf files so only a cache miss hits the | |
| # SourceForge download that regularly hangs the koenig-lexical job. Save | |
| # is a separate step right after the fonts are in place, so a later codec | |
| # or test failure on a cache-miss run still populates the cache. | |
| - name: Restore MS core fonts cache | |
| if: matrix.app == '@tryghost/koenig-lexical' | |
| id: mscorefonts-cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 | |
| with: | |
| path: ~/.cache/msttcorefonts | |
| key: msttcorefonts-ttf-v1 | |
| - name: Install Koenig editor test fonts | |
| if: matrix.app == '@tryghost/koenig-lexical' | |
| timeout-minutes: 10 | |
| env: | |
| DEBIAN_FRONTEND: noninteractive | |
| DEBCONF_NONINTERACTIVE_SEEN: "true" | |
| run: | | |
| FONT_CACHE="$HOME/.cache/msttcorefonts" | |
| FONT_DIR=/usr/share/fonts/truetype/msttcorefonts | |
| sudo mkdir -p "$FONT_DIR" | |
| if ls "$FONT_CACHE"/*.ttf >/dev/null 2>&1; then | |
| echo "Restoring MS core fonts from cache" | |
| sudo cp "$FONT_CACHE"/*.ttf "$FONT_DIR"/ | |
| else | |
| echo "Cache miss — installing msttcorefonts from apt" | |
| sudo sh -c "echo ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true | debconf-set-selections" | |
| sudo apt-get update -yq -o Acquire::Retries=3 -o Acquire::http::Timeout=30 | |
| installed= | |
| for i in 1 2 3; do | |
| if sudo timeout -k 5 120 apt-get install -yq \ | |
| -o Acquire::Retries=3 -o Acquire::http::Timeout=30 msttcorefonts; then | |
| installed=1; break | |
| fi | |
| echo "msttcorefonts attempt $i failed/stuck, retrying..."; sleep 5 | |
| done | |
| if [ -z "$installed" ]; then | |
| echo "msttcorefonts failed after 3 attempts"; exit 1 | |
| fi | |
| mkdir -p "$FONT_CACHE" | |
| cp "$FONT_DIR"/*.ttf "$FONT_CACHE"/ | |
| fi | |
| sudo fc-cache -f | |
| - name: Save MS core fonts cache | |
| if: matrix.app == '@tryghost/koenig-lexical' && steps.mscorefonts-cache.outputs.cache-hit != 'true' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 | |
| with: | |
| path: ~/.cache/msttcorefonts | |
| key: msttcorefonts-ttf-v1 | |
| - name: Install Koenig editor test media codecs | |
| if: matrix.app == '@tryghost/koenig-lexical' | |
| timeout-minutes: 10 | |
| run: pnpm exec playwright install-deps firefox | |
| - name: Run Playwright tests | |
| run: pnpm nx run ${{ matrix.app }}:test:acceptance | |
| - name: Get App Name | |
| if: always() | |
| id: app_name | |
| # trim '@tryghost/' prefix for better readability in test report artifact names; | |
| # resolve the project root since apps live in apps/* and koenig/* | |
| run: | | |
| APP_NAME="${{ matrix.app }}" | |
| APP_ROOT=$(pnpm -s nx show project ${{ matrix.app }} --json | jq -r .root) | |
| { | |
| echo "name=${APP_NAME#@tryghost/}" | |
| echo "root=$APP_ROOT" | |
| if [ -d "$APP_ROOT/playwright-report" ]; then | |
| echo "has_report=true" | |
| else | |
| echo "has_report=false" | |
| fi | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Upload test results | |
| if: always() && steps.app_name.outputs.has_report == 'true' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: ${{ steps.app_name.outputs.name }}-playwright-report | |
| path: ${{ steps.app_name.outputs.root }}/playwright-report | |
| retention-days: 30 | |
| - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # 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_ghost-cli: | |
| name: Ghost-CLI tests | |
| needs: [job_setup, job_pack] | |
| if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| 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. | |
| # Ghost-CLI still requires the package/ prefix; switch this to | |
| # ghost-release-tarball once it accepts a prefix-free archive too. | |
| - name: Download npm tarball | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: ghost-npm-tarball | |
| - run: mv ghost-*-npm.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 | |
| # --force skips Ghost-CLI's version comparison between the archive and | |
| # the installed release. Without it this step is coupled to how far the | |
| # branch has drifted from main: a PR branched before the last release | |
| # carries an older ghost/core version than what's on npm, and the CLI | |
| # refuses with "Version in archive file ... is less than the current | |
| # active version". Equal versions are just as bad in the other | |
| # direction — the CLI logs "All up to date!", never installs the | |
| # archive, and the curl below silently smoke-tests the published Ghost | |
| # instead of this build. --force makes the update path run either way. | |
| run: | | |
| DIR=$(mktemp -d) | |
| ghost install local -d "$DIR" | |
| ghost update -d "$DIR" --force --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@12da0671df2e249a65c467340262e6c4251d9565 # 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_stripe_fixtures: | |
| runs-on: ubuntu-latest | |
| needs: [job_setup] | |
| if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.affected_projects_str != '' | |
| name: Stripe fixture checks | |
| steps: | |
| - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | |
| - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 | |
| - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| # Asserts the fake Stripe server against responses captured from Stripe, and | |
| # that it refuses the requests Stripe refuses. Needs no Ghost, no Docker and | |
| # no browser, so it does not belong in the e2e matrix that waits on the image. | |
| - name: Check Stripe fixtures | |
| run: pnpm --filter @tryghost/e2e test:fixtures | |
| - uses: tryghost/actions/actions/slack-build@e7a401946f91165a6426290705f501a377ec1533 # 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_admin: | |
| name: Build Admin | |
| needs: [job_setup] | |
| # Root of the build + browser-E2E lane (see run_e2e in job_setup). | |
| if: needs.job_setup.outputs.run_e2e == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # Admin's nx build fans out across the whole frontend graph (ghost-admin, | |
| # admin-x-*, shade, koenig-lexical) via nx dependsOn rather than package | |
| # deps, so a filtered install would miss pieces — install the full workspace. | |
| run: pnpm install --frozen-lockfile | |
| - name: Build admin | |
| # IS_SHIPPING enables the Sentry vite plugin in koenig-lexical: it | |
| # injects debug IDs into the built editor bundles and uploads their | |
| # sourcemaps. Only shippable builds (main + tags in the canonical | |
| # repo) get it — debug-ID artifact bundles are release-agnostic, so | |
| # per-merge uploads are deduplicated by content and create no | |
| # release noise in Sentry. | |
| env: | |
| IS_SHIPPING: ${{ github.repository == 'TryGhost/Ghost' && (github.ref == 'refs/heads/main' || github.ref_type == 'tag') && 'true' || '' }} | |
| VITE_SENTRY_AUTH_TOKEN: ${{ secrets.VITE_SENTRY_AUTH_TOKEN }} | |
| run: | | |
| # The Sentry plugin only warns when the token is missing, which | |
| # would silently skip the upload AND poison the Nx cache with a | |
| # never-uploaded debug ID — fail loudly instead. | |
| if [ -n "$IS_SHIPPING" ] && [ -z "$VITE_SENTRY_AUTH_TOKEN" ]; then | |
| echo "::error::IS_SHIPPING is set but VITE_SENTRY_AUTH_TOKEN is empty — Koenig sourcemaps would not reach Sentry" | |
| exit 1 | |
| fi | |
| # Builds apps/admin/dist AND ghost/core/core/built/admin (asset-delivery). | |
| pnpm nx run @tryghost/admin:build | |
| # The built admin (ghost/core/core/built/admin) is consumed by both job_pack | |
| # (packed into the Ghost-CLI archive) and job_docker (COPYed into the full | |
| # image). Ship it as a tarball to preserve file modes and speed transfer. | |
| # | |
| # --exclude '*.map': admin sourcemaps (~60MB) are uploaded to Sentry during | |
| # the build (IS_SHIPPING), not shipped in the image. ghost/core's `files` | |
| # field strips them from the Ghost-CLI archive (!core/built/**/*.map), so | |
| # excluding them here matches that for the Docker image too. | |
| - name: Pack admin build | |
| run: tar --exclude='*.map' -czf admin-build.tar.gz -C ghost/core/core/built admin | |
| - name: Upload admin build artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: admin-build | |
| path: admin-build.tar.gz | |
| retention-days: 1 | |
| if-no-files-found: error | |
| - 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 | |
| - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # main | |
| if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| with: | |
| status: ${{ job.status }} | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| outputs: | |
| admin-artifact-id: ${{ steps.upload-admin.outputs.artifact-id }} | |
| job_pack: | |
| name: Build Ghost-CLI archive | |
| needs: [job_setup, job_build_admin] | |
| # Same availability window as the rest of the build lane; feeds job_ghost-cli | |
| # and (on tags) publish_ghost. | |
| if: needs.job_setup.outputs.run_e2e == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| # Themes (content/themes/casper|source) are submodules packed into the | |
| # archive — without them the tarball ships empty theme dirs. | |
| submodules: true | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| env: | |
| FORCE_COLOR: 0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # pack only needs ghost and its dependency subgraph (dev deps included, to | |
| # build the prod closure below) — not the whole monorepo. | |
| run: pnpm install --frozen-lockfile --filter "ghost..." | |
| - 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 production closure and server | |
| # pack.mjs's `pnpm pack` only tars existing build/ output; it does not build. | |
| # Build ghost's prod workspace closure (kg-*/adapters; i18n has no build | |
| # script) + ghost's own server output first. nx-free recursive filters. | |
| run: | | |
| pnpm --filter-prod "ghost^..." -r run build | |
| pnpm --filter ghost run build:tsc | |
| pnpm --filter ghost run build:assets | |
| - name: Download admin build | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: admin-build | |
| - name: Extract admin build | |
| # The archive includes core/built/admin via ghost/core's files allowlist. | |
| run: | | |
| mkdir -p ghost/core/core/built | |
| tar -xzf admin-build.tar.gz -C ghost/core/core/built | |
| - name: Build standalone distribution | |
| # Invoke the pack script directly (not `nx run ghost:archive`, whose | |
| # dependsOn would rebuild admin/tsc/assets we already have). | |
| run: pnpm --filter ghost run archive | |
| # pack.mjs emits the same tree in two layouts. The prefix-free tarball is | |
| # the release asset; the -npm one carries the package/ prefix npm and | |
| # today's Ghost-CLI need, and goes away with the npm publish in 7.0. | |
| - name: Upload release tarball | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: ghost-release-tarball | |
| path: | | |
| ghost/core/ghost-*.tgz | |
| !ghost/core/ghost-*-npm.tgz | |
| retention-days: 7 | |
| if-no-files-found: error | |
| - name: Upload npm tarball | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: ghost-npm-tarball | |
| path: ghost/core/ghost-*-npm.tgz | |
| retention-days: 7 | |
| if-no-files-found: error | |
| - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # 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_docker: | |
| name: Build Docker Images | |
| # NOT gated on job_build_admin or job_build_e2e_public_apps: the core image | |
| # needs neither. It builds concurrently and only synchronises on the admin | |
| # artifact (via wait-for-artifact) just before the full image, and on the | |
| # e2e-public-apps artifact just before the E2E image. | |
| needs: [job_setup] | |
| # Root of the build + browser-E2E lane (see run_e2e in job_setup). | |
| if: needs.job_setup.outputs.run_e2e == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| with: | |
| persist-credentials: false | |
| # The deploy stage packs content/themes/casper|source (submodules). | |
| submodules: true | |
| - name: Determine build version | |
| # Baked into the images as GHOST_BUILD_VERSION (server-reported version). | |
| # Empty on tags → the ARG default "" is used. No node/pnpm setup in this | |
| # job (the build is in-container), so read the version with jq. | |
| run: | | |
| if [ "${{ github.ref_type }}" != "tag" ]; then | |
| PKG_VERSION=$(jq -r .version ghost/core/package.json) | |
| echo "GHOST_BUILD_VERSION=${PKG_VERSION}+${GITHUB_SHA:0:7}" >> $GITHUB_ENV | |
| fi | |
| - name: Determine push strategy | |
| id: strategy | |
| run: | | |
| # Only the canonical repo publishes to GHCR by default. | |
| # Direct clones, external forks, and cross-repo PRs use artifact-based image transfer instead. | |
| USE_ARTIFACT="false" | |
| if [ "${{ github.repository }}" != "TryGhost/Ghost" ]; then | |
| # Non-canonical repo - 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 canonical tag pushes (release images must always be published) | |
| IS_TAG="${{ startsWith(github.ref, 'refs/tags/v') }}" | |
| if [ "$IS_TAG" = "true" ] && [ "${{ github.repository }}" = "TryGhost/Ghost" ]; 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: Setup Docker Registry Mirrors | |
| uses: ./.github/actions/setup-docker-registry-mirrors | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - name: Log in to GitHub Container Registry | |
| if: steps.strategy.outputs.should-push == 'true' | |
| uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Docker meta (core) | |
| id: meta-core | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # 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@dc802804100637a589fabce1cb79ff13a1411302 # 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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 | |
| env: | |
| BUILDKIT_PROGRESS: plain | |
| with: | |
| # Context is the repo root: the Dockerfile deploy stage runs `pnpm deploy` | |
| # against the workspace. core needs no admin, so it builds now while | |
| # job_build_admin runs concurrently. | |
| context: . | |
| 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 | |
| # PRs read cache-main but never write cache: the old cache-pr-N was | |
| # write-only (cache-from is always cache-main) and mode=max cache export | |
| # is the heaviest GHCR push in the run — skipping it on PRs cuts push | |
| # volume with no rebuild cost. Only main/tag publish cache-main. | |
| cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-core-name) || '' }} | |
| # Uploaded here, before the full image is even built: on the artifact path | |
| # consumers (Ghost-Moya CD) need the core image — server only, no admin — | |
| # and would otherwise have to fall back to `docker-image-production`. | |
| - name: Save core image as artifact | |
| if: steps.strategy.outputs.use-artifact == 'true' | |
| run: | | |
| IMAGE_TAG=$(echo "${{ steps.meta-core.outputs.tags }}" | head -n1) | |
| echo "Saving image: $IMAGE_TAG" | |
| # Written outside the repo root: a stray tarball there would change the | |
| # `.` context between the core and full builds and bust the deploy-stage | |
| # COPY cache (same reason the admin artifact lands in RUNNER_TEMP below). | |
| docker save "$IMAGE_TAG" | gzip > "${RUNNER_TEMP}/docker-image-core.tar.gz" | |
| ls -lh "${RUNNER_TEMP}/docker-image-core.tar.gz" | |
| - name: Upload core image artifact | |
| if: steps.strategy.outputs.use-artifact == 'true' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: docker-image-core | |
| path: ${{ runner.temp }}/docker-image-core.tar.gz | |
| retention-days: 1 | |
| if-no-files-found: error | |
| # Synchronise with job_build_admin only now, after core is built: the full | |
| # image is core + admin. No `needs` edge, so the two jobs run concurrently | |
| # and core builds during the wait. | |
| - name: Wait for admin build | |
| uses: ./.github/actions/wait-for-artifact | |
| with: | |
| artifact-name: admin-build | |
| job-name: Build Admin | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download admin build | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: admin-build | |
| # Outside the build context: a stray admin-build.tar.gz in the repo root | |
| # would change the `.` context between the core and full builds and bust | |
| # the deploy-stage COPY cache, re-running the whole closure build. | |
| path: ${{ runner.temp }}/admin-artifact | |
| - name: Extract admin build into context | |
| # The full stage COPYs ghost/core/core/built/admin from the context; the | |
| # deploy stage excludes core/built entirely, so admin never leaks into core | |
| # and adding it is the only context change between the core and full builds. | |
| run: | | |
| mkdir -p ghost/core/core/built | |
| tar -xzf "${RUNNER_TEMP}/admin-artifact/admin-build.tar.gz" -C ghost/core/core/built | |
| - name: Build & push full image | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 | |
| env: | |
| BUILDKIT_PROGRESS: plain | |
| with: | |
| # Same repo-root context as core (admin now present at | |
| # ghost/core/core/built/admin, excluded by the deploy stage) so the | |
| # deploy/install/build layers cache-hit from the core build above. | |
| context: . | |
| 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: ${{ steps.strategy.outputs.should-push == 'false' }} | |
| 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 | |
| # See core image above: PRs skip cache-to; only main/tag publish cache-main. | |
| cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-full-name) || '' }} | |
| # The production image artifact is saved before the e2e steps below: | |
| # Ghost-Moya CD discovers `docker-image-production` by name in this run | |
| # (cd.yml / cd-server.yml on the artifact path), so an e2e-lane failure | |
| # must not prevent its upload. | |
| - name: Save full image as artifact | |
| if: steps.strategy.outputs.use-artifact == 'true' | |
| 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 | |
| if: steps.strategy.outputs.use-artifact == 'true' | |
| 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 | |
| if: steps.strategy.outputs.use-artifact == 'true' | |
| 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 | |
| # ---- Build the ghost-e2e image here, while the production "full" image is | |
| # still warm in this job's BuildKit cache. The e2e image is FROM the full | |
| # image + a COPY of the public-app UMD bundles + 6 ENV vars. Building it in | |
| # this same job (same buildx builder) means the base resolves from the local | |
| # BuildKit cache instead of a 54s re-pull on a separate fresh runner. | |
| # ghost-e2e stays a SEPARATE tag, so the deployed production image stays clean. | |
| # | |
| # On tag runs every e2e step below is continue-on-error: this job gates the | |
| # release lane (publish_ghost ← job_ghost-cli), so an e2e-lane failure must | |
| # not strand a half-published release (GHCR images pushed, npm not) — and | |
| # release.js --skip-checks can bypass a red e2e lane pre-tag but has no | |
| # override inside this run. The e2e tests on the tag run still fail visibly | |
| # if the image is missing or broken. | |
| - name: Resolve full image tag for E2E base | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| env: | |
| FULL_IMAGE_TAGS: ${{ steps.meta-full.outputs.tags }} | |
| run: | | |
| # Use the first full-image tag as the FROM base for the e2e build. | |
| GHOST_IMAGE_TAG="${FULL_IMAGE_TAGS%%$'\n'*}" | |
| echo "GHOST_IMAGE_TAG=${GHOST_IMAGE_TAG}" >> "$GITHUB_ENV" | |
| - name: Wait for public app artifacts (e2e) | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: ./.github/actions/wait-for-artifact | |
| with: | |
| artifact-name: e2e-public-apps | |
| job-name: Build E2E Public App Assets | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| # Extract into a clean staging dir and build from there: the tarball holds | |
| # exactly the apps/*/umd paths the Dockerfile COPYs, while the workspace at | |
| # this point is dirty (node_modules, dist/ outputs from build:production) | |
| # and the root-anchored .dockerignore patterns don't cover the nested | |
| # copies — using `.` as context would ship hundreds of MB into the builder | |
| # and let stray build:production files under apps/*/umd leak into the image. | |
| - name: Download public app artifacts (e2e) | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: e2e-public-apps | |
| path: ${{ runner.temp }}/e2e-context | |
| - name: Extract public app artifacts (e2e) | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| run: | | |
| tar -xzf "${RUNNER_TEMP}/e2e-context/e2e-public-apps.tar.gz" -C "${RUNNER_TEMP}/e2e-context" | |
| rm "${RUNNER_TEMP}/e2e-context/e2e-public-apps.tar.gz" | |
| # On the artifact (fork/cross-repo) path the full image is only loaded into | |
| # the local docker daemon (never pushed), so the default docker-container | |
| # buildx driver — which resolves FROM via a registry — cannot see it. Spin up | |
| # a second buildx builder on the host `docker` driver, which shares the | |
| # daemon's image store, and use it only for the e2e build on this path. The | |
| # production builds keep using the default builder so their registry cache | |
| # import/export keeps working. | |
| - name: Set up Docker Buildx (e2e host driver) | |
| if: steps.strategy.outputs.use-artifact == 'true' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| id: buildx-e2e-host | |
| with: | |
| driver: docker | |
| - name: Docker meta (e2e) | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| id: meta-e2e | |
| uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 | |
| with: | |
| images: ${{ steps.strategy.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 | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 | |
| env: | |
| BUILDKIT_PROGRESS: plain | |
| with: | |
| # Artifact path: use the host `docker`-driver builder so FROM resolves the | |
| # locally loaded full image. Registry path: empty → default (docker-container) | |
| # builder, where the just-built full image's layers are warm in BuildKit cache. | |
| builder: ${{ steps.strategy.outputs.use-artifact == 'true' && steps.buildx-e2e-host.outputs.name || '' }} | |
| context: ${{ runner.temp }}/e2e-context | |
| file: e2e/Dockerfile.e2e | |
| # GHOST_IMAGE is the full image tag we just built. On the registry path | |
| # it was pushed moments ago by the same buildx builder, so its layers are | |
| # already in this builder's BuildKit cache — FROM resolves from cache, not | |
| # a full re-download. On the artifact path the full image was load:true'd | |
| # into the local daemon, so it resolves there. | |
| build-args: | | |
| GHOST_IMAGE=${{ env.GHOST_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', steps.strategy.outputs.image-e2e-name) || '' }} | |
| # See core image above: PRs skip cache-to; only main/tag publish cache-main. | |
| cache-to: ${{ steps.strategy.outputs.should-push == 'true' && github.event_name != 'pull_request' && format('type=registry,ref={0}:cache-main,mode=max', steps.strategy.outputs.image-e2e-name) || '' }} | |
| - name: Save E2E image as artifact | |
| if: steps.strategy.outputs.use-artifact == 'true' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| 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' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: docker-image-e2e | |
| path: docker-image-e2e.tar.gz | |
| retention-days: 1 | |
| # `Inspect image size and layers` above only runs on the artifact path, where | |
| # the image is loaded into the local daemon. On the push path nothing is | |
| # loaded, so size comes from the registry manifest instead — and is compared | |
| # against the image CI built for the base commit, which is what makes a | |
| # dependency's cost visible on the PR that adds it. | |
| # | |
| # Last in the job on purpose: these steps are informational, and job_docker | |
| # gates the e2e lane and the release lane. They never delay the e2e image, and | |
| # on tag runs they never strand a half-published release. | |
| - name: Report image size | |
| if: steps.strategy.outputs.should-push == 'true' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| shell: bash | |
| env: | |
| CORE_IMAGE: ${{ steps.strategy.outputs.image-core-name }} | |
| FULL_IMAGE: ${{ steps.strategy.outputs.image-full-name }} | |
| CORE_TAGS: ${{ steps.meta-core.outputs.tags }} | |
| FULL_TAGS: ${{ steps.meta-full.outputs.tags }} | |
| BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} | |
| run: | | |
| set -uo pipefail | |
| # Sum of compressed layer sizes — what a pull actually costs. buildx | |
| # attaches a provenance manifest, so a tag resolves to an index: pick the | |
| # real platform manifest out of it before summing. | |
| layer_bytes() { # <image> <tag> | |
| local image="$1" raw digest | |
| raw=$(docker buildx imagetools inspect "${image}:$2" --raw 2>/dev/null) || return 1 | |
| if jq -e 'has("manifests")' <<< "$raw" > /dev/null 2>&1; then | |
| digest=$(jq -r 'first(.manifests[] | select((.platform.architecture // "unknown") != "unknown") | .digest) // empty' <<< "$raw") | |
| [ -n "$digest" ] || return 1 | |
| raw=$(docker buildx imagetools inspect "${image}@${digest}" --raw 2>/dev/null) || return 1 | |
| fi | |
| jq -e '[.layers[].size] | add' <<< "$raw" 2>/dev/null | |
| } | |
| mib() { awk -v b="$1" 'BEGIN {printf "%.1f MiB", b / 1048576}'; } | |
| delta() { awk -v b="$1" 'BEGIN {printf "%s%.1f MiB", (b < 0 ? "-" : "+"), (b < 0 ? -b : b) / 1048576}'; } | |
| # Every main build tags `sha-<short>` (metadata-action type=sha), so the | |
| # PR base commit / previous main commit is an exact like-for-like baseline. | |
| # The zero SHA is what a branch's first push reports as `before`. | |
| BASE_TAG="" | |
| case "$BASE_SHA" in | |
| ""|0000000*) ;; | |
| *) BASE_TAG="sha-${BASE_SHA:0:7}" ;; | |
| esac | |
| { | |
| echo "## Docker image size" | |
| echo "" | |
| echo "Compressed layer totals from the registry manifest." | |
| echo "" | |
| echo "| Image | This build | \`${BASE_TAG:-no baseline}\` | Delta |" | |
| echo "|---|---|---|---|" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| report() { # <label> <image> <tag-list> | |
| local label="$1" image="$2" tag size base | |
| tag=$(head -n1 <<< "$3") | |
| tag="${tag##*:}" | |
| if ! size=$(layer_bytes "$image" "$tag"); then | |
| echo "| $label | inspect failed | | |" >> "$GITHUB_STEP_SUMMARY" | |
| return | |
| fi | |
| if [ -n "$BASE_TAG" ] && base=$(layer_bytes "$image" "$BASE_TAG"); then | |
| echo "| $label | $(mib "$size") | $(mib "$base") | **$(delta $((size - base)))** |" >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "| $label | $(mib "$size") | not built | - |" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| } | |
| report core "$CORE_IMAGE" "$CORE_TAGS" | |
| report full "$FULL_IMAGE" "$FULL_TAGS" | |
| # Image bytes are gzipped, which flattens exactly the regression this is meant | |
| # to catch: a peer-forked duplicate of a large dependency reads as a couple of | |
| # megabytes. The report stage carries per-package sizes and file counts of the | |
| # pruned production node_modules, so the diff below names the dependency. | |
| # | |
| # Every layer under `report` is already cached from the core build, so this is | |
| # an export rather than a rebuild. The output goes to RUNNER_TEMP for the same | |
| # reason the other artifacts do: a stray file in the repo root would change the | |
| # `.` context and bust the deploy-stage COPY cache. | |
| - name: Build dependency size report | |
| if: steps.strategy.outputs.should-push == 'true' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 | |
| with: | |
| context: . | |
| file: Dockerfile.production | |
| target: report | |
| build-args: | | |
| NODE_VERSION=${{ env.NODE_VERSION }} | |
| outputs: type=local,dest=${{ runner.temp }}/image-report | |
| cache-from: type=registry,ref=${{ steps.strategy.outputs.image-core-name }}:cache-main | |
| - name: Upload dependency size report | |
| if: steps.strategy.outputs.should-push == 'true' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: image-report | |
| path: ${{ runner.temp }}/image-report/image-report.json | |
| if-no-files-found: error | |
| # This job deliberately has no pnpm setup (the build is in-container), but the | |
| # comparison is a plain node script with no dependencies — node alone is enough. | |
| - name: Set up Node.js | |
| if: steps.strategy.outputs.should-push == 'true' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Compare dependency sizes against base commit | |
| if: steps.strategy.outputs.should-push == 'true' | |
| continue-on-error: ${{ startsWith(github.ref, 'refs/tags/v') }} | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} | |
| run: | | |
| set -uo pipefail | |
| # The base commit's own CI run uploaded this artifact. Missing is normal — | |
| # the run may have expired, been skipped, or predate this step — and the | |
| # script renders totals without a baseline in that case. | |
| ARGS=( | |
| "--current=${RUNNER_TEMP}/image-report/image-report.json" | |
| "--baseline-label=${BASE_SHA:0:7}" | |
| ) | |
| RUN_ID=$(gh run list --commit "$BASE_SHA" --workflow ci.yml --limit 1 \ | |
| --json databaseId --jq '.[0].databaseId' 2>/dev/null) || RUN_ID="" | |
| if [ -n "$RUN_ID" ] && gh run download "$RUN_ID" --name image-report \ | |
| --dir "${RUNNER_TEMP}/baseline-report" 2>/dev/null; then | |
| ARGS+=("--baseline=${RUNNER_TEMP}/baseline-report/image-report.json") | |
| fi | |
| node scripts/compare-image-report.js "${ARGS[@]}" >> "$GITHUB_STEP_SUMMARY" | |
| outputs: | |
| use-artifact: ${{ steps.strategy.outputs.use-artifact }} | |
| image-core-tags: ${{ steps.meta-core.outputs.tags }} | |
| image-e2e-name: ${{ steps.strategy.outputs.image-e2e-name }} | |
| image-e2e-tags: ${{ steps.meta-e2e.outputs.tags }} | |
| job_build_e2e_public_apps: | |
| name: Build E2E Public App Assets | |
| needs: [job_setup] | |
| # Root of the build + browser-E2E lane (see run_e2e in job_setup). | |
| # Doubles as public-app build verification and the gate for publish_public_apps: | |
| # the `pnpm --filter @tryghost/e2e build:apps` step below is exactly the | |
| # `nx run-many --target=build --projects='tag:public-app'` build the publish flow | |
| # requires, so there's no separate build_public_apps job. 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 publish flows (publish_ghost here, and | |
| # publish-packages.yml) hold the privileged path. | |
| if: needs.job_setup.outputs.run_e2e == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| 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 \ | |
| apps/admin-toolbar/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_e2e_tests: | |
| name: E2E Tests (${{ matrix.projectName }} ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) | |
| runs-on: ubuntu-latest | |
| needs: [job_docker, job_setup] | |
| # Inherits the run_e2e gate transitively via job_docker (which builds the | |
| # ghost-e2e image). | |
| if: needs.job_docker.result == 'success' | |
| strategy: | |
| fail-fast: true | |
| matrix: | |
| include: | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 1 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 2 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 3 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 4 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 5 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 6 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 7 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 8 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 9 | |
| shardTotal: 10 | |
| - projectName: Main | |
| projects: main | |
| analytics: 'false' | |
| shardIndex: 10 | |
| shardTotal: 10 | |
| - projectName: Analytics | |
| projects: analytics | |
| analytics: 'true' | |
| shardIndex: 1 | |
| shardTotal: 2 | |
| - projectName: Analytics | |
| projects: analytics | |
| analytics: 'true' | |
| shardIndex: 2 | |
| shardTotal: 2 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - name: Setup Docker Registry Mirrors | |
| uses: ./.github/actions/setup-docker-registry-mirrors | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 | |
| - name: Pull or build Tinybird CLI Image | |
| if: matrix.analytics == 'true' | |
| env: | |
| CHANGED_TB_CLI: ${{ needs.job_setup.outputs.changed_tb_cli }} | |
| run: | | |
| COMPOSE_IMAGE="${COMPOSE_PROJECT_NAME:-ghost-dev}-tb-cli" | |
| # GHCR's :latest is only republished from main, so it can't reflect a | |
| # Dockerfile change under review — build from source when this PR | |
| # touches it, otherwise take the prebuilt fast path. | |
| if [[ "$CHANGED_TB_CLI" == 'true' ]]; then | |
| echo "docker/tb-cli changed, building from source" | |
| docker buildx build --load -t "$COMPOSE_IMAGE" -f docker/tb-cli/Dockerfile . | |
| elif 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_docker.outputs.use-artifact }} | |
| image-tags: ${{ needs.job_docker.outputs.image-e2e-tags }} | |
| artifact-name: docker-image-e2e | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| # The Playwright container runs against the host's bind-mounted node_modules, | |
| # but only needs @tryghost/e2e's dependency subgraph — not the whole monorepo | |
| # (admin, apps, ghost/core). Scope the install to cut shard setup time. | |
| run: pnpm install --frozen-lockfile --filter @tryghost/e2e... | |
| - name: Prepare E2E CI job | |
| env: | |
| GHOST_E2E_IMAGE: ${{ steps.load.outputs.image-tag }} | |
| GHOST_E2E_SKIP_IMAGE_BUILD: 'true' | |
| GHOST_E2E_ANALYTICS: ${{ matrix.analytics }} | |
| 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 }} | |
| GHOST_E2E_ANALYTICS: ${{ matrix.analytics }} | |
| E2E_PLAYWRIGHT_PROJECTS: ${{ matrix.projects }} | |
| 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.projectName }}-${{ 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.projectName }}-${{ matrix.shardIndex }} | |
| path: e2e/test-results | |
| retention-days: 7 | |
| - uses: tryghost/actions/actions/slack-build@12da0671df2e249a65c467340262e6c4251d9565 # 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.result == 'failure' | |
| needs: [job_e2e_tests, job_setup] | |
| runs-on: ubuntu-latest | |
| # Posts a comment on the PR via `gh pr comment` when E2E tests fail. | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| strategy: | |
| fail-fast: false | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| 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_setup, | |
| job_admin-tests, | |
| job_acceptance-tests, | |
| job_unit-tests | |
| ] | |
| if: always() && needs.job_setup.outputs.coverage_enabled == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - 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 ember-admin/* apps/ember-admin | |
| - name: Upload Admin test coverage | |
| if: contains(needs.job_admin-tests.result, 'success') | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 | |
| with: | |
| flags: admin-tests | |
| files: apps/ember-admin/coverage/cobertura-coverage.xml | |
| disable_search: true | |
| - 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 | |
| with: | |
| flags: e2e-tests | |
| files: ghost/core/coverage-e2e/cobertura-coverage.xml,ghost/core/coverage-integration/cobertura-coverage.xml | |
| disable_search: true | |
| 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_lint_docs, | |
| job_lint_packages, | |
| job_stripe_fixtures, | |
| job_i18n, | |
| job_build_admin, | |
| job_pack, | |
| job_docker, | |
| job_ghost-cli, | |
| job_admin-tests, | |
| job_unit-tests, | |
| job_acceptance-tests, | |
| job_legacy-tests, | |
| job_apps_acceptance-tests, | |
| job_build_e2e_public_apps, | |
| job_e2e_tests, | |
| publish_public_apps | |
| ] | |
| if: always() | |
| runs-on: ubuntu-slim | |
| 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 | |
| # 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_public_apps: | |
| needs: [ | |
| job_setup, | |
| job_lint, | |
| job_unit-tests, | |
| job_build_e2e_public_apps | |
| ] | |
| name: Publish ${{ matrix.package_name }} | |
| runs-on: ubuntu-latest | |
| # Serialize per-app publishes so two quick main merges can't both compute the | |
| # same next-patch number and collide on npm. Different apps still publish in | |
| # parallel; cancel-in-progress stays false so a queued publish isn't dropped. | |
| concurrency: | |
| group: publish-public-app-${{ matrix.package_name }} | |
| cancel-in-progress: false | |
| 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.job_build_e2e_public_apps.result == 'success' | |
| && needs.job_setup.outputs.publish_public_apps_matrix != '[]' | |
| permissions: | |
| contents: read | |
| id-token: write | |
| strategy: | |
| matrix: | |
| include: ${{ fromJSON(needs.job_setup.outputs.publish_public_apps_matrix) }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Determine release version | |
| id: release | |
| working-directory: ${{ matrix.package_path }} | |
| run: | | |
| # npm is the source of truth for the patch number: compute the next | |
| # patch above what's already published in this app's major.minor line | |
| # (or publish package.json's exact version when starting a fresh | |
| # minor/major line). The patch digit in package.json is otherwise | |
| # unused — Ghost core resolves apps via <pkg>@~<major.minor> on | |
| # jsDelivr, so the newest published patch is what sites receive. | |
| NEXT_VERSION=$(node "$GITHUB_WORKSPACE/scripts/compute-next-app-version.cjs" .) | |
| echo "Publishing ${{ matrix.package_name }}@${NEXT_VERSION}" | |
| # Write it before building so the version baked into the bundle | |
| # (e.g. portal's REACT_APP_VERSION) matches what we publish. | |
| npm pkg set version="$NEXT_VERSION" | |
| echo "current_minor=$(echo "$NEXT_VERSION" | awk -F. '{print $1"."$2}')" >> $GITHUB_OUTPUT | |
| echo "current_major=$(echo "$NEXT_VERSION" | awk -F. '{print $1}')" >> $GITHUB_OUTPUT | |
| - name: Build the package | |
| run: pnpm nx build ${{ matrix.package_name }} | |
| - name: Configure .npmrc | |
| run: | | |
| echo "@tryghost:registry=https://registry.npmjs.org/" >> ~/.npmrc | |
| # --provenance is explicit rather than relying on pnpm's auto-detection, | |
| # which degrades to a warning if it can't confirm visibility. | |
| - name: Publish to npm | |
| working-directory: ${{ matrix.package_path }} | |
| run: | | |
| pnpm publish --access public --provenance --no-git-checks | |
| - name: Replace version placeholders in cdn-paths | |
| id: cdn_paths | |
| run: | | |
| cdn_paths="${{ matrix.cdn_paths }}" | |
| echo "cdn_paths<<EOF" >> $GITHUB_OUTPUT | |
| echo "$cdn_paths" | sed -e 's/CURRENT_MINOR/${{ steps.release.outputs.current_minor }}/g' -e 's/CURRENT_MAJOR/${{ steps.release.outputs.current_major }}/g' >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| - name: Print cdn_paths | |
| run: echo "${{ steps.cdn_paths.outputs.cdn_paths }}" | |
| - name: Purge jsDelivr cache | |
| uses: gacts/purge-jsdelivr-cache@8d92aea944f1a3e8ad70505379e1a8ac72d56b73 # v1 | |
| with: | |
| url: ${{ steps.cdn_paths.outputs.cdn_paths }} | |
| # --------------------------------------------------------------------------- # | |
| # Trigger Pro CD — dispatch to Ghost-Moya cd.yml (runs on main + PRs) | |
| # --------------------------------------------------------------------------- # | |
| trigger_cd: | |
| needs: [job_setup, job_build_admin, job_docker] | |
| name: Trigger Pro CD | |
| runs-on: ubuntu-slim | |
| if: | | |
| always() | |
| && github.repository == 'TryGhost/Ghost' | |
| && needs.job_setup.result == 'success' | |
| && needs.job_build_admin.result == 'success' | |
| && needs.job_docker.result == 'success' | |
| && needs.job_docker.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_admin.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@820762786026740c76f36085b0efc47a31fe5020 # v7 | |
| 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-*-npm.tgz package/package.json | jq -e '.packageManager' >/dev/null | |
| # --provenance is explicit so a publish without an attestation fails the | |
| # job instead of warning. Safe with no repo checkout: the SLSA statement | |
| # is built from the GITHUB_* env vars plus the tarball digest, and the | |
| # subject comes from the packed manifest — nothing reads a working tree. | |
| - name: Publish to npm | |
| run: npm publish ghost-*-npm.tgz --access public --provenance | |
| # NOTE: Publishing the other workspace packages (koenig/*, packages/*, ...) is | |
| # NOT a job here. The release tag triggers publish-packages.yml directly, in | |
| # parallel with this workflow — npm trusted publishing validates the | |
| # entry-point workflow, so that publish must be its own entry point rather than | |
| # a job reached from ci.yml. See .github/workflows/publish-packages.yml. | |
| # --------------------------------------------------------------------------- # | |
| # Trigger docker-library-ghost auto-update — runs after the npm package is | |
| # published so the Docker library picks up the new release. | |
| # --------------------------------------------------------------------------- # | |
| trigger_docker_library_update: | |
| needs: [publish_ghost] | |
| name: Trigger docker-library-ghost update | |
| runs-on: ubuntu-slim | |
| if: | | |
| startsWith(github.ref, 'refs/tags/v') | |
| && github.repository == 'TryGhost/Ghost' | |
| steps: | |
| - name: Dispatch to TryGhost/docker-library-ghost | |
| uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4 | |
| with: | |
| token: ${{ secrets.CANARY_DOCKER_BUILD }} | |
| repository: TryGhost/docker-library-ghost | |
| event-type: ghost-version-publish | |
| # --------------------------------------------------------------------------- # | |
| # 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 | |
| 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 | |
| # Attach the prefix-free tarball so the release is installable without an | |
| # npm client. Same build tree as the npm package, just without the | |
| # package/ wrapper dir. | |
| - name: Download release tarball | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: ghost-release-tarball | |
| path: release-assets | |
| - name: Create GitHub Release | |
| id: create_release | |
| run: | | |
| gh release create "${GITHUB_REF_NAME}" \ | |
| --title "${GITHUB_REF_NAME}" \ | |
| --notes-file /tmp/release-notes.md \ | |
| release-assets/ghost-*.tgz | |
| # Gate on create_release, not notes, so a failed release can't announce success | |
| - name: Notify Slack | |
| if: success() && steps.create_release.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)" | |
| # --------------------------------------------------------------------------- # | |
| # Notify on any release-path failure — fires if the tag was created but the | |
| # publish run (build, npm publish, or GitHub Release) failed at any point. | |
| # A single terminal job catches jobs skipped by an upstream failure, which a | |
| # per-job `if: failure()` step cannot. | |
| # --------------------------------------------------------------------------- # | |
| notify_release_failure: | |
| name: Notify release failure | |
| needs: [job_setup, job_build_admin, job_pack, job_docker, job_ghost-cli, publish_ghost, create_github_release] | |
| if: failure() && startsWith(github.ref, 'refs/tags/v') && github.repository == 'TryGhost/Ghost' | |
| runs-on: ubuntu-slim | |
| permissions: {} # only posts to Slack via curl; needs no GITHUB_TOKEN scopes | |
| steps: | |
| - name: Notify Slack | |
| env: | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| RELEASE_NOTIFICATION_URL: ${{ secrets.RELEASE_NOTIFICATION_URL }} | |
| run: | | |
| VALUE=$(printf '<!subteam^S07ATDH3CLB|on-call-product> — check the failed run: <%s|view run>' "$RUN_URL") | |
| PAYLOAD=$(jq -n --arg value "$VALUE" \ | |
| '{username: "Ghost CI", attachments: [{color: "danger", fields: [{title: "🚨 Ghost release failed", value: $value}]}]}') | |
| curl -sf -X POST -H 'Content-type: application/json' \ | |
| --data "$PAYLOAD" \ | |
| "$RELEASE_NOTIFICATION_URL" || echo "Slack notification failed (non-fatal)" |