Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fb6e295
Added gift delivery choices to Portal (#29968)
kevinansfield Aug 20, 2026
8be837e
Added gift delivery outcome handling (#29969)
kevinansfield Aug 20, 2026
203269b
Extracted the members CSV transform into its own module (#30050)
sagzy Aug 20, 2026
7037de2
Extracted a shared helper from the CSV stream response (#30051)
sagzy Aug 20, 2026
8e5ae73
Extracted theme zipping into a reusable zipToFile (#30052)
sagzy Aug 20, 2026
d344cc8
Generalized the export filename builder beyond CSV (#30053)
sagzy Aug 20, 2026
2a36710
Added the sync site export behind the selfServeArchives flag (#30054)
sagzy Aug 20, 2026
6786c5c
Extracted clean-tokens deletion into a reusable task
allouis Aug 17, 2026
9d1ddc4
Migrated clean-tokens to the class-based jobs service
allouis Aug 17, 2026
c10ff08
Removed NODE_COMPILE_CACHE env var (#30139)
acburdine Aug 20, 2026
80ff757
Improved gift claim deadline consistency (#30136)
kevinansfield Aug 20, 2026
878754c
Changed in-app links to React-owned routes to use the router (#30125)
9larsons Aug 20, 2026
0a8241c
Re-add x402 as a second machine payments rail for Base USDC (#30116)
louisghost Aug 20, 2026
65b2d76
Improved gift presentation (#30067)
kevinansfield Aug 20, 2026
b0af358
Added post export permissions to the backup integration (#30138)
mike182uk Aug 20, 2026
85bc2a3
🐛 Fixed unsafe notification HTML (#29756)
9larsons Aug 20, 2026
452c80a
TypeScriptified "copy to clipboard" portal test (#30141)
EvanHahn Aug 20, 2026
94cbd96
Fixed the admin nx task graph (#30146)
9larsons Aug 20, 2026
e29f9ab
TypeScriptify three Portal tests (#30148)
EvanHahn Aug 20, 2026
713b792
Removed dead multi-app surface from admin-x-framework (#30143)
9larsons Aug 20, 2026
f14cc58
Changed feature flag reads to use the framework hook (#30147)
9larsons Aug 20, 2026
cfa381c
Updated gift subscription Labs copy (#30149)
kevinansfield Aug 20, 2026
b2a4076
🐛 Fixed Pintura config detection reading the wrong config path (#30145)
9larsons Aug 20, 2026
34a3369
Simplified CLI entrypoint, removing unnecessary file (#30085)
EvanHahn Aug 20, 2026
1558cf4
Fixed member commenting mutations not refreshing the members list (#3…
9larsons Aug 20, 2026
6d1018d
Changed duplicated currency and count formatting to shared helpers (#…
9larsons Aug 20, 2026
fdaed07
Removed dead test configuration relics (#30151)
9larsons Aug 20, 2026
9fe1a6f
Fixed two wrong shapes in the fake Stripe subscription builder
rob-ghost Aug 19, 2026
26ec9bc
Added captured Stripe fixtures and a check of the fake server's builders
rob-ghost Aug 19, 2026
c386b60
Changed the fake Stripe server to reject the requests Stripe rejects
rob-ghost Aug 19, 2026
7bef4b3
Added a CI job for the Stripe fixture checks
rob-ghost Aug 19, 2026
0fca96a
Changed the fake Stripe server to read request bodies with schemas
rob-ghost Aug 19, 2026
0f51da3
Changed programmatic navigation to React-owned routes to use the rout…
9larsons Aug 20, 2026
2bc34fc
Improved release packaging process for docker image (#30153)
acburdine Aug 20, 2026
617db22
Deduplicated viem in the production image and added image size report…
acburdine Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
213 changes: 208 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1137,12 +1137,14 @@ jobs:
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-*.tgz ghost.tgz
- 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
Expand Down Expand Up @@ -1192,6 +1194,37 @@ jobs:
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]
Expand Down Expand Up @@ -1342,11 +1375,24 @@ jobs:
# 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-*.tgz
path: ghost/core/ghost-*-npm.tgz
retention-days: 7
if-no-files-found: error

Expand Down Expand Up @@ -1773,6 +1819,152 @@ jobs:
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 }}
Expand Down Expand Up @@ -2149,6 +2341,7 @@ jobs:
job_lint,
job_lint_docs,
job_lint_packages,
job_stripe_fixtures,
job_i18n,
job_build_admin,
job_pack,
Expand Down Expand Up @@ -2356,14 +2549,14 @@ jobs:
run: npm install -g npm@11

- name: Verify tarball contents
run: tar -xOf ghost-*.tgz package/package.json | jq -e '.packageManager' >/dev/null
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-*.tgz --access public --provenance
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
Expand Down Expand Up @@ -2430,12 +2623,22 @@ jobs:
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
--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
Expand Down
12 changes: 11 additions & 1 deletion .pnpmfile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {glob, readFile} from 'node:fs/promises';
// in a published manifest and only adds noise + phantom workspace refs
//
// Applied to the `ghost` package only (the Ghost-CLI release archive built by
// ghost/core/scripts/pack.js):
// ghost/core/scripts/pack.mjs):
// - rewrite its workspace deps to the bundled `file:components/*.tgz`
// tarballs shipped in the archive (name→filename map via GHOST_COMPONENTS)
// - strip `scripts` to the runtime set — Ghost-CLI starts Ghost with `node`,
Expand Down Expand Up @@ -97,6 +97,16 @@ function readPackage(pkg) {
delete pkg.peerDependenciesMeta?.typescript;
}

// abitype's zod peer is only used by its `abitype/zod` subpath, which nothing
// in the tree imports. Left in place it peer-forks abitype and everything
// above it: mppx resolves that chain against zod 4 and @x402/* against zod 3,
// so ghost's production closure carried two identical copies of viem (~2.9k
// files each), ox and abitype.
if (pkg.name === 'abitype') {
delete pkg.peerDependencies?.zod;
delete pkg.peerDependenciesMeta?.zod;
}

return pkg;
}

Expand Down
13 changes: 11 additions & 2 deletions Dockerfile.production
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Targets:
# deploy — build the prod dependency closure + a self-contained /home/ghost
# (build-only stage, never shipped)
# report — per-package sizes of that closure, for CI (build-only, not shipped)
# core — server + production deps, no admin (Ghost-Pro base)
# full — core + built admin (self-hosting)
#
Expand Down Expand Up @@ -78,17 +79,25 @@ RUN --mount=type=cache,target=/root/.local/share/pnpm/store,id=pnpm-store \
# ghost/core/scripts/prune.mts for the rules and the exclusions they carry. The COPY
# layer below is extracted single-threaded, once per CI E2E shard, and that cost
# scales with file count: this removes roughly half of them.
RUN node ghost/core/scripts/prune.mts /home/ghost --profile=image
RUN node ghost/core/scripts/prune.mts /home/ghost --profile=image --report=/image-report.json

# Fail the build now if the native module didn't install correctly (missing/broken
# prebuilt binary) rather than at container runtime.
RUN cd /home/ghost && node -e "require('better-sqlite3'); console.log('better-sqlite3 OK')"

# ---- report: per-package sizes of the shipped node_modules (build-only) ----
# CI extracts this with `--target=report --output=type=local` and diffs it against
# the base commit's report, so a dependency that duplicates a large package (a
# peer-forked viem, say) is visible on the PR that adds it. Every layer in `deploy`
# is already cached by the core build, so this target costs an export, not a build.
FROM scratch AS report

COPY --from=deploy /image-report.json /

# ---- Core: server + production deps ----
FROM node:$NODE_VERSION-bookworm-slim AS core

ENV NODE_ENV=production
ENV NODE_COMPILE_CACHE=/home/ghost/.compile-cache

RUN apt-get update && \
apt-get install -y --no-install-recommends libjemalloc2 fontconfig && \
Expand Down
2 changes: 2 additions & 0 deletions apps/activitypub/src/components/layout/error/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const Error = ({statusCode, errorCode}: {statusCode?: number, errorCode?: string

const toAnalytics = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
// This component is also used by the standalone ActivityPub app, whose
// router does not own the Admin analytics route.
navigate('/analytics/', {crossApp: true});
};

Expand Down
23 changes: 23 additions & 0 deletions apps/activitypub/test/acceptance/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {expect, test} from '@playwright/test';
import {mockInitialApiRequests} from '../utils/initial-api-requests';

test.describe('Error page', () => {
test.beforeEach(async ({page}) => {
await mockInitialApiRequests(page);
});

test('hands the analytics action back to the Admin host', async ({page}) => {
await page.goto('#/does-not-exist');

await page.getByText('Back to the homepage').click();

await expect.poll(async () => {
return await page.locator('body').evaluate((body) => {
return JSON.parse(body.dataset.externalNavigate ?? 'null');
});
}).toMatchObject({
route: '/analytics/',
isExternal: true
});
});
});
Loading
Loading