Skip to content

[pull] main from TryGhost:main - #1431

Merged
pull[bot] merged 35 commits into
code:mainfrom
TryGhost:main
Aug 20, 2026
Merged

pull[bot] merged 35 commits into
code:mainfrom
TryGhost:main

Conversation

@pull

@pull pull Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

kevinansfield and others added 30 commits August 20, 2026 11:37
ref https://linear.app/ghost/issue/BER-3853

This adds a labs-gated gift checkout that lets buyers choose how their gift is delivered and understand the recipient experience before paying.

- Added separate membership and delivery steps so recipient details are collected only when required
- Added email and gift-card previews to make each delivery option clear before checkout
- Passed delivery details through Portal and the Members API, then restored them after Stripe redirects
- Added dedicated beta purchase, success and redemption pages while preserving the existing production flow
- Kept redemption and magic-link gift details consistent, including sender information and price visibility
- Aligned validation, field limits and expiry formatting with backend contracts
- Added responsive and accessible behavior, translations and coverage for both beta and production page mappings
ref https://linear.app/ghost/issue/BER-3851

Track what happens after Mailgun accepts a gift email so permanent delivery failures can be surfaced to the buyer without introducing a competing retry mechanism.

- Recorded delivered, temporary-failure and permanent-failure outcomes with provider timestamps and diagnostic details
- Correlated Mailgun events with gift deliveries using the provider message ID
- Kept Mailgun responsible for retrying temporary failures
- Treated permanent failures as terminal and replay-safe to prevent duplicate notifications
- Notified the buyer with the original redemption link when a valid gift cannot be delivered
- Initialized analytics independently of the feature flag while scheduling collection only after an accepted delivery
- Disabled tracking for gift emails and skipped unnecessary opened-event polling
- Added the migration, failure-email translations and behavioral coverage
ref https://linear.app/ghost/issue/GVA-917

**This PR does not change behaviour.** 

## What moves

The transform that turns member rows into CSV chunks moves out of the
members output serializer (`serializers/output/members.js`) into its own
module, `members-csv-transform.js` — mirroring how the posts CSV
transform already lives in `posts-csv-transform.ts`. The function body
is moved verbatim; the serializer keeps calling the identical code.

## Why

The upcoming site export orchestrator needs to reuse this transform to
produce `members.csv` inside the export zip. Without the extraction it
would have to import the whole members serializer (mappers and all) to
get at one pure function.
ref https://linear.app/ghost/issue/GVA-917

**This PR does not change behaviour.** 

## What moves

`stream-csv-response.ts` owned three concerns that are not CSV-specific:
the `Content-Type`/`Content-Disposition` headers, the `no-transform`
cache directive (so proxies don't recompress and corrupt a byte stream),
and the `pipeline()` teardown wiring. Those move into a generic
`stream-response.ts`; the CSV helper becomes a thin wrapper that pins
its content type and its exact `Missing CSV export filename` error
message (which a unit test asserts).

## Why

The upcoming site export streams a zip and needs the identical response
plumbing. A near-verbatim second copy would let the two silently drift —
e.g. a future Content-Disposition escaping fix landing in only one of
them.
ref https://linear.app/ghost/issue/GVA-917

**This PR does not change behaviour** for the theme download.

## What moves

`ThemeStorage.serve()` built the theme zip inline with
`compress(themePath, zipPath)`. That call moves into a new
`ThemeStorage.zipToFile(themeName, zipPath)` and `serve()` delegates to
it — same arguments, identical zip. The method is also exposed through
the themes storage API with the same installed-theme validation gate
`getZip` applies, and that validation branch gains a unit test.

## Why

The upcoming site export bundles one zip per installed theme, and those
zips must stay byte-compatible with what the theme upload accepts.
Keeping zip creation in exactly one place makes drift between the
download endpoint and the export impossible.
ref https://linear.app/ghost/issue/GVA-917

**This PR does not change behaviour.**

## What changes

`getCSVExportFileName(type)` returns byte-identical filenames, now by
delegating to a `getExportFileName(type, extension)` that can also build
filenames for other extensions.

## Why

The upcoming site export zip uses the same site-title-prefixed naming
convention (`my-site.ghost.export.2026-01-01.zip`), and this file is
documented as the single source of truth for that convention — the API
sets it on `Content-Disposition` and the admin client reads it back.
closes https://linear.app/ghost/issue/GVA-917

Implements the sync delivery mode of the one-click data export, building
on the mockup that landed in #29915 and the four behaviour-preserving
refactors below this PR in the stack. With the `selfServeArchives` labs
flag on, "Export data" now really downloads a full site archive: `GET
/ghost/api/admin/exports/download/` streams one zip composed of content
JSON, members CSV, post analytics CSV, per-theme zips, and
routes/redirects — everything except media, which stays reserved for the
host (async) mode that isn't part of this PR (its dialog branch remains
mocked).

## How the zip is composed

The orchestrator calls the same services the five standalone export
endpoints call — no HTTP self-calls, no background jobs, no duplicated
controller logic:

```mermaid
flowchart LR
    UI["Export data dialog"] -- "GET /exports/download/?components=…" --> C["exports controller"]
    C --> SE["SiteExporter (services/exports)"]
    SE -- "membersService.export({limit:'all'})" --> M["members.csv"]
    SE -- "postsService.export({limit:'all'})" --> A["post-analytics.csv"]
    SE -- "doExport()" --> J["export.json"]
    SE -- "themeStorage.zipToFile()" --> T["themes/{name}.zip"]
    SE -- "routeSettings / customRedirects" --> R["routes.yaml + redirects.yaml"]
    M & A & J & T & R --> Z["archiver zip → streamed response"]
```

Decisions worth reviewing:

- **Streamed, not staged.** The zip pipes to the response while it's
built, so memory stays flat and the download starts immediately. The
price is failure semantics: once headers are sent, a component that
fails to acquire can only be skipped (it's logged server-side and simply
absent from the bundle). A mid-stream failure of a CSV source
deliberately destroys the archive — a visibly broken download beats a
silently incomplete one — and stream lifecycles are tied together in
both directions so a dropped DB connection can't hang the response and a
client disconnect can't pin a DB connection or leak staged temp files.
- **Restorable artifacts.** `export.json` is byte-identical to the
`/db/` download and themes are the exact zips the theme upload accepts,
so every piece of the bundle restores through existing import surfaces —
proven by a round-trip e2e test that re-imports each artifact through
the real import endpoints.
- **Permissions reuse `db.exportContent`** (Owner/Administrator only)
instead of minting a new permission + migration: a site export contains
everything a database export contains, so the same gate applies — and
it's a superset of every composed component's own requirement.
- **Flag-only gating.** The feature stays behind the `selfServeArchives`
labs flag, and that flag is the whole gate — no config capability signal
for deploy skew at this stage. If the flag graduates, feature detection
can come back with the GA work.
- **The dialog downloads through the fetch-based blob helper** rather
than a plain navigation: a navigation download is unobservable from the
page, which would leave the dialog stuck on "Preparing your export…"
forever and swallow errors. The blob approach gives a real "Export
downloaded" state, error feedback with retry, and a working Cancel
(AbortController). Browsers back large blobs with disk, so the zip
doesn't have to fit in tab memory.
- **`archiver` becomes a direct dependency** of ghost/core, but adds no
new code to the tree: `@tryghost/zip` already pins the same
`archiver@8.0.0` internally, and its own file-based `compress`/`extract`
API can't stream a zip into an HTTP response.

## Testing it locally

1. `pnpm dev`, then in Ghost Admin enable **Settings → Labs → Private
features → Self-serve archives** (developer experiments must be on).
2. Go to **Settings → Import/Export → Export** — the individual export
buttons are replaced by one **Export data** button.
3. Pick components and hit Export: the dialog shows "Preparing your
export…" with a working Cancel, and flips to "Export downloaded" when
the zip lands in your downloads.
4. Restore-compatibility: the zip's `export.json` imports via the
universal importer, `members.csv` via the members importer,
`themes/*.zip` via theme upload, `routes.yaml`/`redirects.yaml` via
their uploads.
5. With the flag off, the Export tab is byte-identical to `main`.

## Automated tests

- `SiteExporter` unit tests cover the failure paths: component skipping,
partial themes, mid-stream teardown, client-abort cleanup.
- E2E tests are split across two files by necessity: the 4xx cases
(validation, labs gate, permissions) use the in-process agent, while the
actual downloads run over real HTTP like the theme download tests — the
in-process agent's mock socket never signals `drain`, deadlocking any
streamed body larger than the write buffer.
- The download test seeds more posts than the posts exporter's default
page cap, guarding against silent truncation (an unlimited export once
defaulted to 15 posts).
- A round-trip e2e test re-imports every artifact through the real
import endpoints, so format drift fails CI instead of surfacing as a
broken restore.
- Admin acceptance tests cover both dialog modes, component selection,
and the download-complete and download-failed states.
ref https://linear.app/ghost/issue/HKG-1970

A preparatory, behaviour-preserving refactor before migrating clean-tokens onto
the jobs service. The token-deletion query is lifted out of the legacy Bree
worker into a plain, idempotent cleanTokens({db}) function (a Fowler Extract
Function), so the existing scheduler and the coming class-based handler run the
exact same unit of work and the migration that follows is a pure wiring change
rather than a rewrite.
ref https://linear.app/ghost/issue/HKG-1970

Replaces the legacy Bree token-cleanup worker with a CleanTokensJob dispatched
through the class-based jobs service, the first recurring job to move across. The
job carries no payload - its schedule is the only input - and delivery rehydrates
a fresh instance before running the handler, which the central registration step
wires to the shared cleanup task. Boot owns scheduling the recurring job.
ref https://linear.app/ghost/issue/BER-3856/

Gift expiry was calculated and interpreted differently across
persistence, delivery, email, and Portal. Using one publication-local
deadline keeps every surface aligned and avoids relying on lifecycle
sweep timing.

- Calculated claim deadlines at the end of the publication-local calendar day
- Enforced deadlines during redemption and delivery before cleanup jobs run
- Formatted dates consistently in Portal and emails using the site locale and timezone
- Added safe fallbacks for invalid locale, timezone, and expiry values
no ref

Follow-up to #30122. Reproduced on `main`: sidebar → Tags → tag row
(plain anchor) → edit → back — the tag-detail blocker prompts, but
**Stay lands on `/site`**: the router can't compute the delta for the
untracked `/tags/:slug` entry it's leaving, so its revert jumps the
wrong way. Before #30122 this was a silent unblock; after, a wrong jump
(or a `history.go(0)` reload when the target is the first entry). Same
shape on the members list, whose rows navigated with
`window.location.hash`.
ref https://linear.app/ghost/issue/BER-3856/

Gift delivery needed a clearer recipient experience while keeping redemption errors private

- Redesigned gift delivery emails with responsive publication and tier presentation
- Used live publication details with a publication-name fallback when no icon exists
- Preserved personal messages, tier benefits and expiry details
- Aligned ending-reminder CTAs with the delivery email
- Added translated, privacy-safe feedback for invalid or missing gift links
ref https://linear.app/ghost/issue/GVA-907/

- Ghost(Pro) archives are gaining a `post-analytics.csv` alongside the
existing `members.csv`, fetched from `GET
/ghost/api/admin/posts/export/` with the `ghost-backup` integration key,
and that endpoint is gated on `browse post`
- There is no explicit export posts permission, so we use browse posts -
exactly how `browse member` was added to this role in v5.121.0 to allow
the members export
- This grants no data the key could not already reach, in either half of
that CSV. Post content: `posts` is in the exporter's `TABLES_ALLOWLIST`,
so a plain `GET /db/` already returns every post's title, html and
lexical. The analytics counts: they derive from `emails`,
`email_recipients`, `members_click_events` and `members_feedback`, none
of which are in that allowlist - but `exportContent` accepts an
`include` option validated against `BACKUP_TABLES` (`db.js:45-56`),
which contains all of them, so `GET
/db/?include=emails,members_click_events` works with today's key
- So what the permission actually adds is a presentation: asking Ghost
to join those tables into a CSV, rather than dumping them and joining
them by hand
- It does open `/posts/` browse generally rather than only the export
route, since permissions are per action type rather than per endpoint
- Applied in fixtures and with a migration so both new and existing
sites get the update, and mirrored into the test fixtures
- Verified against a site created before this change, so fixtures never
re-ran and only the migration could have applied it: the role gains
`post|browse`, and the `ghost-backup` key then gets a 200 from
`/posts/export/?limit=all`. A Zapier key is still refused on `/db/`
no ref
- Sanitised notification messages on write and again on read so existing
stored notifications are covered.
- Reused the existing notification-email sanitisation policy for Admin
notifications.
- Preserved semantic HTML and safe links while removing scripts, event
handlers, unsafe URLs, images, layout wrappers, and all inline CSS.
- Rejected malformed non-string messages and safely neutralised legacy
malformed values.

Ghost Admin renders notification messages as HTML. The
notification-creation permissions are restricted separately in #29754,
but sanitisation is still needed at the rendering trust boundary for
service-generated messages and existing stored data.

This follows the server-side sanitisation direction reported by @pptx704
in #29746. The client-side text rendering from that PR is intentionally
omitted because normal release notifications rely on links.
no ref

Two wiring problems in `@tryghost/admin`'s nx targets, both fixed in its
`package.json` only (root `nx.json` untouched):

- The bare `"build"` entry in `build.dependsOn` was a self-reference
that nx silently drops, so the target had no `^build` — the workspace
libraries (admin-x-framework, shade, koenig-lexical, activitypub) were
only built transitively because `ghost-admin:build` happens to depend on
them. `build:dev` had the same bare `"build"` entry, which there
resolved to admin's own full prod build target instead. Both now use
`^build` (the explicit `ghost-admin` entries stay — the ember-assets
plugin consumes its output).
- `test:unit` inherited the root targetDefaults `dependsOn: ["build"]`,
building admin's full prod bundle plus the Ember app before running
jsdom unit tests that only need the workspace libraries' output. A
project-level override makes it depend on `^build` only.
no ref

No changes needed, just changing the extension.
no ref

The framework serves exactly two consumers — the React admin shell
(`apps/admin`) and the route-composed ActivityPub app
(`apps/activitypub`) — but still carried exports from its
multi-micro-frontend era. Every removal was grep-verified to have zero
consumers outside the framework:

- Dead root-barrel exports: unused types (`BaseAppProps`,
`FrameworkProviderProps`, `RouterProviderProps`, …), react-router
re-exports (`NavLink`, `matchPath`), `getToken`, source-utils names, and
redundant root copies of `/hooks`-entry exports; the unused
`usePermission` public export (the hook stays internal to the query
factories)
- Unused resource hooks: `useDeleteComment`, `useCommentReplies`,
`useSearchIndexPosts`, `useNewsletterStatsByNewsletterId`,
`useSubscriberCountByNewsletterId`, `useBrowseLabels`, `getLabelBySlug`,
`getPage`, `getTag`, `getTier`, `getNewsletter`, `getMemberCustomField`,
`getFileUrl`
- ActivityPub hook remnants (`useFollow`, `useUnfollow`,
`useBrowseInboxForUser`, `useBrowseFollowingForUser`,
`useBrowseFollowersForUser`) plus the now-unused `useActivityPub`
query-factory option and `activityPubRoot` path plumbing — the app
imports types only, and all types stay; the separate `mockApi`
`useActivityPub` option in `src/test/acceptance.ts` (used by the
ActivityPub Playwright suite) is untouched
- `createPaginatedQuery` and its sole dependency `use-pagination`
- The `queryClientOptions` FrameworkProvider prop and its
QueryClient-constructing branch (the `queryClient` test-override prop
stays)
- The never-provided scroll-restoration context (`useScrollRestoration`,
`saveScrollPosition`/`getScrollPosition`) and `useBaseRoute`;
`ScrollRestoration` + `resetScrollPosition` stay
- `createVitestConfig` (`src/test/vitest-config.ts`) — apps use
`@internal/cfg-vitest`
The settings area carried its own `useFeatureFlag` copy built on
`useGlobalData`, and a few components read `config.labs.<flag>` straight
off the config object. Both paths resolve the same react-query config
query that `@tryghost/admin-x-framework`'s `useFeatureFlag` reads, so
everything now uses the framework hook and the local copy is deleted.

- 9 consumers of `@/settings/hooks/use-feature-flag` switched to the
framework import (test mock path updated alongside); the local hook file
is deleted
- Raw `config.labs` reads converted: `invite-user-modal` +
`role-selector` (`superEditors`), `navigation-modal`
(`navigationIcons`), `tiers` (`machinePayments`); now-unused
`useGlobalData` imports dropped where the labs read was their last use
- Left alone by design:
`flag-gated-route.tsx`/`use-flag-gated-route-owner.ts` (deliberate
standalone config fallback — now the only remaining `config.labs` read
in admin src) and `settings/advanced/labs/*` (reads/writes the labs
settings JSON, a different concern)
no issue

The private feature still described dropped customization work and omitted the durations and delivery options now being shipped.
no ref

The server emits `pintura` at the top level of the public config
(`ghost/core/core/server/services/public-config/config.js`) and Ember
Admin reads it there (`config.pintura?.js || settings.pinturaJsUrl`).
The framework's `usePinturaConfig` looked under
`config.hostSettings.pintura`, which nothing emits — a repo-wide grep
finds that path only in the hook itself.

Consequence: hosts that configure Pintura via server config fell back to
the `pintura_js_url`/`pintura_css_url` settings; when those are unset,
Pintura silently failed to open for the framework-hook consumers — the
tag feature image editor, the member welcome-email editor, and the
automations email editor.

Changes:
- `use-pintura-config.ts` reads top-level `config.pintura`; the
settings-URL fallback is unchanged
- The `pintura?: {js?, css?}` field on the `Config` type moves out of
the `hostSettings` block to the top level, matching what the server
sends
- Hook test fixtures updated to the top-level shape; the case proving
the settings-URL fallback (config without `pintura`) stays
no ref

`index.js` was simply a pass-through to `ghost.js`. Let's just put
everything in `index.js` and remove `ghost.js`.

This change should have no user impact, but should (slightly) hasten
startup time and simplifies the code a little.
…0150)

no ref

`createMutation`'s `invalidateQueries` option could name exactly one
dataType, so `useDisableMemberCommenting`/`useEnableMemberCommenting`
invalidated only `CommentsResponseType` — and the two screens that flip
commenting (member detail modal, member actions menu) carried manual
`queryClient.invalidateQueries(['MembersResponseType'])` workarounds to
refresh the members list.

- `invalidateQueries`' dataType form now accepts `string | string[]`;
the existing behaviour (queryKey-prefix invalidation + one Ember-bridge
`onInvalidate` per dataType) loops per entry. The `{filters, options}`
form is untouched, and the 34 existing single-dataType declarations
behave identically.
- The commenting hooks declare both `CommentsResponseType` and
`MembersResponseType`. No third dataType is needed: the single-member
read (`getMember`) registers under the same `MembersResponseType`
dataType, so the prefix invalidation covers list + detail.
- The two manual workarounds (and their `useQueryClient` imports) are
deleted.
- Ember bridge: both dataTypes are already in `state-bridge.js`'s
`emberDataTypeMapping` (mapped to `null`, React-only), so the
per-dataType `onInvalidate` no-ops rather than throws.
- New framework unit case proves multi-dataType invalidation: both
listed dataTypes' cached queries invalidate, an unrelated one does not,
`onInvalidate` fires once per dataType.
…30152)

no ref

Centralized currency handling into framework.
no ref

A cleanup pass over the React admin packages' test configuration, all
inert leftovers:

- `jest-extended` in apps/admin: the only "extended" matcher used
anywhere is `toHaveBeenCalledOnce` (11 call sites), which is native in
Vitest 4 — two of those sites are acceptance specs whose setup never
loaded jest-extended, independently proving it
- The duplicate `ResizeObserver` mock in admin's setup file —
`setupShadeMocks()` already installs one, so the `??` fallback never
fired
- The redundant per-file `setupShadeMocks()` call in
`filter-relative-date.test.tsx` (the global setup file runs it for every
test file)
- The `minThreads`/`maxThreads` CI blocks in framework/shade vite
configs — not valid top-level test options in Vitest 4 (moved to
`poolOptions` at 1.0), silently ignored
- The `process.env.VITEST_SEGFAULT_RETRY` defines in `adminXViteConfig`
and shade's config — the env var was only read by Vitest ≤ 0.34, and a
build-time define never reached the runner anyway (koenig has the same
define; left alone, outside this scope)
- Legacy `.eslintrc.cjs` files under `admin-x-framework/test` and
`shade/test` — both packages are on flat config; `eslint test/` passes
after deletion
- The stale `'!apps/admin-x-activitypub'` exclusion in the root vitest
config (package renamed long ago)
- The `@types/jest` catalog entry (zero consumers; never materialized in
the lockfile)
- Renamed activitypub's `test/unit/utils/pending-activity.ts` →
`.test.ts` for naming consistency — it already ran (the include glob
matches every file under test/unit), so suite counts are unchanged
ref https://linear.app/ghost/issue/BER-3872

Stripe returns a legacy `plan` projection alongside `items.data[0].price` on
every subscription, and Ghost reads that spelling in three files including the
comped check in member-repository, so subscriptions built without it exercised
none of those paths. Separately, `buildSubscription` spread `id: opts.priceId`
into the price unconditionally, which overwrote the generated price id with
undefined whenever the caller did not pass one.
ref https://linear.app/ghost/issue/BER-3872

The fake Stripe server's response shapes were written from Stripe's docs and never compared against Stripe itself, so nothing caught them being wrong. A builder emitting a key Stripe does not return describes an API that does not exist, and a builder omitting a key Ghost reads fails silently: the property access yields undefined, the branch behind it never runs, and the suite stays green. These fixtures are captured from Stripe test mode at API version 2020-08-27, the version ghost/core pins, and the capture script strips the capturing account's identity as it writes. A completed checkout has to be captured separately with one manual card entry, because Stripe blocks automating its hosted checkout page.
ref https://linear.app/ghost/issue/BER-3872

The fake accepted anything, so a checkout session Stripe would refuse passed
in tests and failed in production. That is what took the Stripe Tax private
beta down twice in May 2024 (ONC-10 and ONC-35). The three limits it now
enforces are measured against the live API with the committed probe script
rather than read from the docs or Stripe's OpenAPI spec, because the spec
carries no maxItems on custom_fields and states the customer_update rule only
in prose, so a schema-driven check would have accepted all three.
rob-ghost and others added 5 commits August 20, 2026 16:35
ref https://linear.app/ghost/issue/BER-3872

The fixture checks assert the fake Stripe server against captured Stripe
responses and need no Ghost, no Docker and no browser, so running them inside
the e2e matrix would make them wait on the image build for no reason. They get
their own job instead, wired into the required-tests gate so the checks cannot
silently stop running.
ref https://linear.app/ghost/issue/BER-3872

The server hand-rolled sixteen parse functions to coerce form-encoded bodies,
where every scalar arrives as a string and bracket-notation arrays sometimes
arrive as index-keyed objects. Their return types were hand-written by indexing
into the builder types, with nothing checking that the two agreed. Zod schemas
now declare each request body once and the types are inferred from them. The
coupon route keeps its hand-written validation because it deliberately rejects
what Stripe rejects, which is a different job from lenient coercion.
Characterisation tests written against the previous behaviour before the
refactor cover the change.
…er (#30144)

no ref

Cross-app navigations write `location.hash` directly, which the React
router never sees: they create untracked history entries, bypass the
`useBlocker` unsaved-changes guards, and are recorded rather than
performed by the acceptance harness, leaving those flows untestable.
Only Ember-owned targets (the editor, posts list, `/site`, `/pro`,
`/migrate/*`) need the hash write, because Ember only observes
`hashchange`.

- Analytics + post-analytics: metric tiles, chart clicks, post links and
breadcrumbs to `/posts/analytics/*`, `/analytics/`, `/members?filter=…`
and `/settings/analytics` now navigate through the router (also the
ActivityPub error page's `/analytics/` action). Ember-owned targets keep
`crossApp`.
- The two mixed-destination post links (`getPostDestination` returns
`/editor/post/:id` or `/posts/analytics/:id`) pick `crossApp` per
destination — `useIsEmberOwnedRoute` where the destination is in render
scope, a `startsWith('/editor/')` check inside the per-row handler.
- `RouteAccessGuard` denied redirects switch from a hash write to
`<Navigate to="/" replace />`, so blocked access no longer stacks a
history entry; its unit and acceptance specs now assert the real
resulting route.
- Settings: `design-modal`'s setup-flow close and `user-detail-modal`'s
close/not-found now navigate to `/analytics` / `/` through the router
instead of `isExternal` hash writes.
- Two raw `history.replaceState` calls that stripped a query from the
hash wiped react-router's own history state (breaking later
navigations): `design-and-theme-modal` now strips via
`updateRoute({replace: true})` preserving the original history shape,
and `member-emails` clears its `verifyEmail` token via
`setSearchParams(..., {replace: true})`. The member-emails path had no
coverage before — a new non-automations acceptance spec verifies the
token submit + clean `/settings/memberemails` end state.
no ref
- attach packed tarball (without package/ suffix) to github release, to be used by the docker official image
- include prune.mts script in pack tarball, so docker can use it to prune files and not duplicate the work
…ing to CI (#30157)

`@x402/*` landed as a second machine-payments rail and, like `mppx`
before it, pulls in `viem`. It did not share the copy `mppx` already had
— the production image carried two.

## Why there were two

`abitype` declares `zod` as an optional peer, used only by its
`abitype/zod` subpath, which nothing in the tree imports. pnpm resolved
it anyway and the peer suffix propagated up through `ox` and `viem` to
their dependents:

- `mppx` → `zod@4.4.3` → `viem@2.55.11(zod@4.4.3)`
- `@x402/evm`, `@x402/extensions` → `zod@3.25.76` →
`viem@2.55.11(zod@3.25.76)`

Same version, two byte-identical unpacks. Same for `ox@0.14.33`,
`abitype@1.2.3` and `abitype@1.3.0`.

Dropping the peer in `.pnpmfile.mjs` (the same hook already used for
`consolidate`, `knex` and the `typescript` peer on these very packages)
collapses each to one instance. Both zod majors stay installed — that is
a genuine version conflict between `mppx` and `@x402/*`, not a peer
artefact.

Measured across two full `Dockerfile.production` builds:

| | before | after | delta |
|---|---|---|---|
| shipped production tree | 195.9 MiB | 184.9 MiB | **−11.1 MiB** |
| files | 32,245 | 28,763 | **−3,482** |

`viem` alone is 7.4 MiB and 2,894 of those files. File count matters as
much as bytes: the deploy `COPY` layer is extracted single-threaded,
once per CI E2E shard.

Verified with a real install — the four `@x402/*` and `mppx` entrypoints
load, and the 50 machine-payments unit tests pass.

## Why CI did not catch it

`Inspect image size and layers` is gated on the artifact path — forks
and cross-repo PRs, where the image is loaded into the local daemon.
Canonical PRs push straight to GHCR and never load, so the PRs that
actually change the image reported nothing.

Two additions, both at the end of `job_docker` so they delay neither the
e2e image nor anything downstream, and both `continue-on-error` on tags
so an informational step can never strand a half-published release:

- **`Report image size`** sums compressed layer sizes from the registry
manifest for the core and full images, against the `sha-<short>` tag CI
already publishes for the PR base commit.
- **A `report` build stage** carries per-package byte and file counts of
the pruned production `node_modules` (`prune.mts --report`), diffed
against the base commit's uploaded report by
`scripts/compare-image-report.js`.

The per-package view is there because image bytes hide the regression
worth catching: a peer-forked duplicate of `viem` is ~2.9k files but
gzips to roughly 2 MB of a 152 MB pull, which reads as noise. Run
against the tree from before the first commit, the diff names it
outright:

```
### Duplicated packages (4, 11.1 MiB recoverable)

| Package          | Copies | Recoverable |
|------------------|--------|-------------|
| `viem@2.55.11`   | 2      | 7.4 MiB     |
| `ox@0.14.33`     | 2      | 3.0 MiB     |
| `abitype@1.3.0`  | 2      | 0.4 MiB     |
| `abitype@1.2.3`  | 2      | 0.4 MiB     |
```

Every layer under the `report` target is already cached from the core
build, so the extra build is an export rather than a rebuild.

## Notes

- The first PR after this merges will show "no baseline" — the base
commit's run predates the artifact. Self-corrects.
- `actionlint` reports no new findings; `scripts` lint is clean and its
109 tests pass, including 15 new ones.
@pull pull Bot locked and limited conversation to collaborators Aug 20, 2026
@pull pull Bot added the ⤵️ pull label Aug 20, 2026
@pull
pull Bot merged commit 617db22 into code:main Aug 20, 2026
1 check passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants