Skip to content

🐛 Fixed periodic host limits in React Admin settings #54432

🐛 Fixed periodic host limits in React Admin settings

🐛 Fixed periodic host limits in React Admin settings #54432

Workflow file for this run

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)"