[pull] main from TryGhost:main - #1461
Merged
Merged
Conversation
The calendar opens on the current month, so these tests depended on the wall clock as well as their August 2026 fixtures: they passed all August because the two agreed, then failed everywhere on September 1st. Pinning the test clock inside the fixture month makes the suite deterministic whatever the real date is. Only Date is faked, so timers and rendering behave as before.
no ref This change should not change behaviour. The calculated `allow_self_signup` setting has only read `members_signup_access` since its formula was simplified in e67e241 (Dec 2024), but `portal_plans` and the Stripe keys were left in the `dependents` list. Dependents only control when the field is recalculated, so edits to any of those settings have been triggering a pointless recalculation that always produced the identical value.
The container's process tree (pnpm → sh → nodemon → sh → node) forwarded no signal to Ghost, so a compose down killed it before any registered cleanup task ran. The entrypoint now stays PID 1 and signals the Ghost process directly, waiting for it to exit.
…version `stripe listen` renders every event at the account's default API version rather than the version Ghost pins on the endpoint it registers in production, so a payload seen locally can differ in shape from the one production receives. `pnpm dev:stripe:remote` publishes the dev gateway through Tailscale Funnel and lets Ghost register its own pinned endpoint on boot; the endpoint is marked ephemeral so it is deleted on shutdown, and the dev container entrypoint now forwards SIGTERM to the Ghost process so that shutdown actually runs on container stop.
…version Stripe renders each event at the API version of the endpoint that received it, and Ghost reads fields from where its pinned version puts them, so an event at another version can be misread without any failure. Ghost now compares the version an event declares with the one it pins and logs an error naming both, while still handling the event so a mismatch never causes Stripe to retry indefinitely.
… kind The pill kept its own hard-coded operator list — the text vocabulary — whatever the field held, so the first non-text field would have offered comparisons its codec cannot write and rewritten operators it can. Nothing about that list was the pill's to decide: the registry owns what each kind of value's comparisons mean, and the addressing layers the presence pair on top, because every custom field is optionally set on a member. A field's filtering is now derived — its kind names a registry type, and that type's vocabulary plus presence is everything the pill offers. The one declaration left is a line per kind, exhaustive, so a kind the registry cannot yet express fails the build instead of borrowing text, while a type of a kind it already speaks — a date alongside created_at — inherits everything and owes nothing.
An addressing bound to one field answered for every field: it read the key out of the clause and claimed it, so whichever bound entry was asked first parsed another field's values with its own semantics. Identical results while every field is text, silently wrong the day two fields differ. A bound addressing now refuses clauses naming any other key, leaving them to their own field's entry or to the unbound template.
The definitions were only requested once the URL's filter already named a custom field, but a pill added through the picker reaches the URL on the first keystroke — so the pill was born under the static template, which types every custom field as text, and only afterwards did the typed catalog exist. The definitions are now wanted whenever the feature flag is on. The filter bar already fetches the same query for its picker, so the common members view makes no extra request.
A composite's parts each follow their own rule, but the rule was all a part carried: a country code and a postal code were both anonymous string schemas by the time anything outside the catalog saw them. Each part now declares a type alongside its rule, defined once in a catalog keyed by the type itself, so a part cannot be tagged as something its rule is not, and the parts listing carries the type to whoever renders or filters a part. The scalar short_text field type and the short_text part type are the same thing in different positions, so the field type reads its value from the part catalog rather than describing it twice.
A composite's filter type was declared as text at the kind level, which reads as a claim about composites when it is really a fact about their parts. Scalar kinds keep their map, exhaustive so a kind the registry cannot express fails the build. A composite now derives its filter type from what its parts declare, and refuses parts whose declared types filter differently, because the engine reads a composite with a single semantics; whoever first maps a part type away from its siblings is pointed at the per-part dispatch that change needs rather than left to misread values silently.
The default now receives webhooks the way production does, through Ghost's own registered URL over a Tailscale tunnel. The Stripe CLI forwarder sits behind an explicit --listen option that warns at startup, because forwarded events carry the account's default API version and their shape has already misled development once.
fixes https://linear.app/ghost/issue/BER-3890 Persisted the personalised gift checkout draft in session storage so returning from Stripe restores the delivery step without losing buyer or recipient input. Explicit plan and delivery routes preserve browser navigation for direct and in-Portal entry points, while shared abandonment actions and a valid checkout success clear saved personal data.
no ref Shade defined the same color scale twice — `--color-grey-*` and `--color-gray-*` — and usage split roughly 60/40 between the spellings. One spelling now: `gray`.
no ref `cancelling a subscription` ends the moment the captured PUT lands, but the screen refetches the member right after the mutation. That refetch is still inside `interceptMemberRead`'s `route.fetch()` when Playwright starts closing the context, so `response.json()` rejects with "Response has been disposed". The handler already meant to swallow that, but keyed it on `page.isClosed()`, which is still false at that point — the response is disposed before the page reports itself closed. Key it on the error instead, which is the only signal available that early in teardown.
no issue The gift delivery date picker passed no `defaultMonth` to react-day-picker, which then falls back to today's month. Two consequences: - Reopening the picker after choosing a date in another month showed the wrong month instead of the chosen date's month. - The component's rendering depended on the real-world clock, so the unit tests — which select days around a fixed date — only passed while the current month happened to coincide with the fixture month. They broke on the September 1st month boundary and are currently failing on every branch, including `main`. The calendar now opens on the selected date's month, falling back to the first selectable month before anything is chosen. That makes the component deterministic from its props, which fixes the UX quirk and makes the existing tests date-independent with no clock faking. All 22 date picker tests pass on a date outside the fixture month. Before fix: https://github.com/user-attachments/assets/ed5fbfa2-7dac-41da-9c3c-d0e21275a8a7 After fix: https://github.com/user-attachments/assets/14f2783a-f6bf-4fd1-af5f-cf59c1dd231b
no ref - speed up acceptance tests runs by only caching the coverage xml artifact, rather than the full v8 profiles
no ref - update pnpm to latest v12 release, with the rust rewrite
Member custom fields are becoming metafields: fields addressed as namespace.key, with the publisher's fields living in a reserved custom namespace so that app-owned namespaces can arrive later without renaming anything. This adds the vocabulary both tiers must agree on before any surface converts: the dotted identity form with its positional parse, the metafields qualifier each serialized context writes exactly once, and the registry of namespaces that exist. It lives beside the csv export in the shared package for the same reason that does: core and admin disagreeing about how a field is named is a filter, file, or error path that silently stops matching.
The definitions API moves from /members/custom_fields to /members/metafields/:namespace, with the envelope renamed to members_metafields and each definition now carrying its namespace, which is the reserved `custom` value for every publisher-defined field. The storage layer stays untouched: the domain model declares the namespace as a literal and the codec injects it on read and strips it on write, so when app-owned namespaces arrive only the codec and a migration change. Routes under a namespace that does not exist 404, and a namespace clause in the definitions filter is refused with the route named as the way to scope, since the table has no such column for it to reach. On the admin side the query hooks now unwrap the envelope before handing data to components, so the wire shape has exactly one owner and the next rename of this kind stops at the framework file. This is behind the membersCustomFields flag, which keeps its name: it names the publisher-facing feature, not the wire.
Values on the member resource move from a flat custom_fields bag to metafields, nested one level by namespace, with custom as the only namespace that exists; the browse include token, the body schema, and every error property path follow, so a rejected write names the field by its full address, like metafields.custom.shipping_address.country, and a malformed values object is addressed as metafields.custom while a bad namespace level is addressed as metafields. Values stay flat inside the service — the values service unwraps the namespace level on the way in and the member serializer adds it on the way out, refusing a namespace that does not exist rather than dropping it. An older core rejects the new include token outright, but the flag is off everywhere while this is pre-release and production only turns it on after the backend rollout completes, so deploy-order safety is operational rather than a version check in the client. Import failure reports name fields by the new address ahead of the CSV columns converting, which is the next change in this series.
The members filter grammar moves its relation alias to metafields and its key clause to the field's full identity, so a filter reads (metafields.key:'custom.company'+metafields.value:'Ghost') and a composite's part is one leaf address, metafields.key:'custom.shipping_address.country'. Because the identity grammar already covers part paths, the leaf address absorbs both the value.<part> dotted attribute and the separate path attribute, collapsing the grammar to two attributes: presence is the bare key clause or its negation, and a value clause matches at exactly the leaf the identity names. The transformer parses identities through the shared vocabulary and fails closed on a namespace that does not exist or a bare one-segment key, since either would otherwise silently match nothing. The admin filter codec and its bespoke parser follow the same shape, and filter field ids and list column keys become the full address, so a field is spelled identically in a filter, a column, an error path, and a CSV header once the columns convert next.
Export columns move from custom_fields.<key> to the field's full address, metafields.custom.<key> with a part suffix for composites, so a column is spelled the same way as the filter, the error path, and the API payload that carry the same field. The importer keeps reading the old vocabulary as a legacy fallback, preferring the current column when a file somehow carries both, because exported files live on disk indefinitely and a rename must not strand them; nothing writes the old columns any more. The import mapping UI derives suggested names from the new segments and recognises both vocabularies through the shared column check, which also replaces a hand-rolled prefix test the error-report builder had drifted onto.
Namespaces were half-introduced as a compile-time registry, which made every future namespace a code event when they are going to arrive as data, with an app that installs. The registry is gone: an unknown namespace is now the same non-event as an unknown key. A browse of it is an empty collection, a filter on it matches nobody, and a write into it is refused as an unknown field by its full address, so a namespace gaining its first field starts working with no change above the storage layer. Values travel keyed by field identity, every admin filter id, column key, CSV column and value lookup derives from the namespace each field carries, and the member payload nests by whatever namespaces the backend serializes. The one hard-coded namespace left is at the query boundaries, where storage that predates namespace storage implicitly holds the publisher's fields: the definitions codec and scoping, the values lookup and read grouping, the filter transformer's condition mapping, and the checkout bindings store. Once a leaves view carries namespace and key columns, each of those boundaries collapses into a column condition. A side effect worth naming: a __proto__ key sent as a field name is now refused loudly as an unknown field instead of being silently dropped, which matches how every other unrecognised name is treated.
…mespace The picker was the last place in Admin that spelled a metafield address by hand, writing the publisher's namespace into a template literal twice. Every other surface derives addresses through the addressing module, so a field list that one day carries another namespace would have worked everywhere but the picker. Routing both entries through metafieldFieldId makes the namespace flow from the definition itself, and typing the helper's return after the metafields prefix lets the picker accept it without a cast. The acceptance fake for definitions was untyped, so its fixtures could omit the namespace the picker now reads; typing it against MemberCustomField makes the compiler hold every fixture to the shape the real API serves.
An audit of every comment this branch added kept only the ones stating an external constraint and deleted the rest. Where a comment was compensating for the code, the code changed instead: the admin filter grammar and the CSV columns now derive identities through the shared package rather than re-implementing the format, the key-minting allowlist and the identity parser share one segment rule so they cannot drift, identity part paths are named partPath, identity-keyed records say so in their types, and the filter transformer names its any-path and match-nothing cases instead of asserting them in prose. The member edit request now always asks for metafields back: the conditional include only catered to backends older than the feature, which no environment runs.
The importer kept reading the pre-rename custom_fields.* columns so an old export would re-import without remapping. No such export exists outside development, because the feature has never been on in production, and the mapping step already lets a publisher point any column of any file at any field. The fallback goes, along with its tests and the last fixtures written in the old vocabulary.
…30376) ref https://linear.app/ghost/issue/ONC-1962 An email unsubscribe link carries a `uuid` and `key` identifying whose subscription it manages, but Portal's unsubscribe page routed the actual update through the browser's member session whenever one existed. If the link belonged to a different member — a forwarded email, a shared device, or staff opening a reader's link — the logged-in member was silently unsubscribed instead of the link's owner, and their newsletter selection was overwritten with the other member's list. The unsubscribe flow auto-executes on page load, so a single open of someone else's link was enough. A mismatched session almost always signals a mistake, and because the page auto-executes with no confirmation, proceeding against either member risks a change nobody intended. Rejection is the only outcome with no wrong victim: the page now shows an error explaining the link belongs to a different email address than the one the browser is signed in with. The rejection happens before the page fetches the link member's data or auto-runs anything, and the error screen replaces the preferences UI entirely — so nothing is read or written for either member, and nothing about the other subscriber is exposed to the session. The logged-out flow and the same-member flow are unchanged: without a session the link works as before, and the member's own link still updates via the session so the Portal member context stays in sync. The page is only reachable when both `uuid` and `key` are present (enforced in `app.jsx`), so the guard never strands a legitimate flow without a write path. Tests: added a regression test covering opening another member's link while logged in — it asserts the error is shown and that no member data is read or written. The existing logged-in test now also pins that the member's own link updates via the session endpoint. The new error string is extracted for translation with the standard i18n extraction, including translator context.
ref https://linear.app/ghost/issue/PLA-338/post-release-consolidate-filters-ui-in-admin ## Why The React posts and pages lists introduce updated filter controls. Members and Comments should use the same visual details while that experience is enabled, without changing their existing flag-off UI. ## What changed - Uses `ListFilter` and `ListFilterPlus` in Members and Comments when `postsListReact` flag is enabled - Makes the active Add filter controls icon-only and outlined while retaining their accessible names - Makes Clear controls outlined and text-only under the same flag - Matches the Members Clear/Save action spacing to Posts
no ref - changeset is written by pnpm, including it in oxfmt just creates conflict between the two systems
no ref The members list writes its own query string back after something changes it — closing the import modal applies the import's label filter — and that write is a navigation of its own. One landing right after a member row is clicked replaces the detail route with the list again: the detail chunk loads, the member is never fetched, and the assertion waits out its timeout on the list. `import-custom-fields.test.ts` hit this on roughly a quarter of attempts and lost all three retries on 0b8af54, taking the E2E shard red. Re-click the row when the detail screen does not appear, rather than waiting out a timeout on a screen the app navigated away from. The app-side race is real and stays open: a member opened straight after an import can send the reader back to the list.
The changesets check compared the base branch tip against the PR head as a plain tree diff, so a branch that had not rebased onto the newest main was charged with every package main had changed in the meantime, while the version-bump and migration-integrity checks rooted their diffs correctly but fed the merge-base a base SHA frozen into the event payload, which goes stale the other way. Base resolution now lives in one tested helper that prefers the live tip of the base ref CI fetches just before the checks run, falls back to the payload SHA, and confines every comparison to the merge-base so a branch only answers for its own changes. It keeps the pre-commit hook's staged-tree comparison working without leaking git errors, and refuses loudly when no merge-base can be found. The base-ref fetch steps pass the branch name through the environment rather than shell interpolation and force-update their tracking refs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 : )