Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 77 additions & 16 deletions docs/bundle-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,13 @@ stand as written.
"sourceInstallType": "production",
"kind": "mysql-dump",
"ghost": {
"version": "5.130.3"
"version": "6.2.0"
},
"url": "https://example.com",
"adminUrl": "https://admin.example.com",
"database": {"path": "database.sql"},
"content": "content/",
"config": {
"url": "https://example.com",
"mail__options__auth__pass": "p$ssword",
"mail__from": "'Acme Support' <support@example.com>"
}
Expand All @@ -52,9 +55,30 @@ stand as written.
| --- | --- |
| `bundleVersion` | Must be `1`. |
| `bundleCreatedAt` | **Required.** RFC 3339 timestamp in UTC. |
| `sourceInstallType` | **Required.** Exactly `local` or `production`. The importer infers the installation mode from this field. |
| `kind` | Bundle kind. Validated against the kinds the importer supports. |
| `ghost.version` | Exact source Ghost version. Validated as supported; the import happens *at* this version, and upgrading is a separate operation. |
| `sourceInstallType` | **Required.** Exactly `local` or `production`. Derived from the source instance’s actual local/production process classification; the importer selects its mode from this field. |
| `kind` | Required `mysql-dump` or `portable`. MySQL/mysql2 or local SQLite development respectively. |
| `ghost.version` | Exact source Ghost 6.x version. Validated as supported; the import happens *at* this version, and upgrading is a separate operation. |

| `url` | Required public URL, unchanged from source config. |
| `adminUrl` | Optional separate admin URL, unchanged. |
| `database.path` | Required relative path: `database.sql` for MySQL, content JSON for portable. |
| `database.members` | Required for portable only; relative members CSV path. A successful zero-byte export means no members; skip member import for that file. |
| `content` | Required `content/` asset root. |
| `config` | Required raw string map, described below. |

Portable `database` example (filenames can vary; always read the manifest):

```json
{
"path": "content/data/content-from-v6.2.0-on-2026-09-14-12-00-00.json",
"members": "content/data/members-from-v6.2.0-on-2026-09-14-12-00-00.csv"
}
```

`kind` appears only at the top level and the version only at `ghost.version`.
There are no `database.kind`, `ghostVersion`, or `sourceEnvironment` aliases.
Matching exporter fixtures are in `tests/fixtures/migration-bundle-v1/` and
Ghost-CLI's `test/fixtures/migration-bundle-v1/`.

A bundle missing `bundleCreatedAt` or `sourceInstallType`, or carrying a
`sourceInstallType` outside that set, is rejected. There is no inference
Expand All @@ -78,7 +102,7 @@ including in directory bundles.
in [configuration.md](configuration.md) and at the top of
[scripts/lib/env.sh](../scripts/lib/env.sh).

So a mail password of `p$ssword` appears in the manifest as the four-character
So a mail password of `p$ssword` appears in the manifest as the
JSON string `"p$ssword"`, and reaches `ghost.env` as `mail__options__auth__pass="p$$ssword"`.
An exporter that pre-quotes or pre-escapes a value produces a corrupted
import, and the round trip is tested through real Docker Compose containers
Expand Down Expand Up @@ -119,13 +143,50 @@ journals. It is about isolating untrusted bundle content: path traversal,
absolute member paths, escaping links, and unbounded expansion are all
properties of a file someone else produced.

## Not settled in this step

The following are defined by their own steps and are **not** promised here:

- Exporter behaviour, including the documented final-export mode that leaves
the source stopped for cutover, and the preserved restart behaviour for
ordinary exports (S3).
- The portable-import fidelity matrix and the explicit list of losses, which
is established from fixtures and real exporter/importer behaviour (S3/S5).
- The import sequence, isolated destination, verification and cutover (S5).
## Source consistency and cutover (S3)

Ghost-CLI's `ghost migrate-export --leave-stopped` deliberately leaves the source
stopped after successful export and attempts to stop it after export failure.
Preflight rejection leaves the source unchanged. Ordinary exports restore its
original running state, including a stopped portable source temporarily started
for the API. Failed exports remove partial outputs. Recovery is `ghost start`
in the original installation; verify `ghost ls` if a lifecycle operation failed.

Portable sources are local SQLite development sites only. Capture order is content
JSON → members CSV → stop Ghost → copy assets. Users must avoid editing throughout
export. This is sequential capture, **not an atomic snapshot or write freeze**;
`bundleCreatedAt` is manifest creation time. Production SQLite and unsupported
clients are rejected. MySQL is stopped before copying assets and dumping its DB;
external database writers must also be quiescent.

Directories/files/archives are private from creation (`0700`/`0600`). Existing
outputs, source overlap (including symlink aliases), and archive collisions are
refused before source lifecycle changes. Supported content includes hidden files,
full themes, settings, files/images/media, and data redirects. Runtime logs/apps,
SQLite files, external storage and custom adapters are omitted. Individual theme
directory links under `content/themes/` are resolved and materialized as regular
directories, including CLI defaults linked through `current` and external
development themes. Output may not overlap their resolved targets. Broken/cyclic
theme links, links to non-directories, nested links and other content links/special
files are rejected explicitly by the exporter.

Portable content/member files preserve Ghost API response bytes, not a complete
database. Author data travels but reusable staff authentication does not; IDs may
be remapped by import. Default content export omits integrations/API keys/webhooks,
member and subscription relationship tables, comments and event/email history.
CSV carries member fields and customer/tier references, not complete paid
subscription or per-newsletter relationships. Re-establish staff access and
integrations; reconnect/reconcile Stripe using a supported importer.

See the exporter's [fidelity and recovery documentation](https://github.com/TryGhost/Ghost-CLI/blob/claude/ghost-cli-migration-export-c00253/docs/migration-bundle.md).
S3 verifies schema, source lifecycle, private output, system-tar extraction and
real Compose value transport. S5 must qualify destination owner setup, ID mapping,
member imports and subscription reconciliation end to end before promising their
fidelity. S3 does not implement the Docker importer.

## Remaining S5 work

The importer, isolated destination, verification, and ingress cutover are S5.
Keep the final source stopped and intact until the destination is accepted;
restarting it permits writes that invalidate the final snapshot. Real production
cutover must prevent writes before the final MySQL export.
5 changes: 5 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ atomically with a restrictive umask and preserve the mode of an existing file.

## Value encoding

Migration bundle v1 config values are raw strings, with no dotenv encoding. The
importer applies the rules below exactly once when writing `ghost.env`; public
and admin URLs are separate manifest fields mapped to `.env`. See
[bundle-v1.md](bundle-v1.md) for the agreed S3 schema and source guarantees.

Compose interpolates dotenv values, **including inside double quotes**, and
`env_file` values are no exception. A literal dollar sign must be written `$$`.
There is no quoting a person naturally reaches for that avoids this, and the
Expand Down
11 changes: 7 additions & 4 deletions docs/ghost-cli-replacement.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,10 @@ Before freezing the contract:
- Record the consistency/cutover behavior: the exporter currently restarts Ghost.
Add a documented final-export mode that leaves the source stopped, with explicit
operator selection, and preserve the current restart behavior for ordinary exports.
Portable exports require a running source; final export must arrange a write freeze
before taking API/content snapshots, then leave it stopped for cutover.
Portable exports support local SQLite development sites only: content API export,
then members CSV, then stop Ghost and copy assets. Captures are sequential; users
must avoid editing during export. Do not implement write-freeze machinery or
require Ghost changes. Final export leaves the source stopped for cutover.

Import sequence:

Expand Down Expand Up @@ -855,8 +857,9 @@ quoted-config decoding, or a sourceEnvironment fallback for mode selection:
this format has not shipped.

Implement deliberate final-export/cutover behavior while preserving ordinary
export restart semantics. Address write-freeze and snapshot consistency for
portable exports; document precisely which data/relationships the portable
export restart semantics, including originally stopped portable sources. Keep
portable capture sequential for local SQLite development sites without write-freeze
machinery or Ghost changes; document precisely which data/relationships the portable
format cannot preserve. Use lib/tasks/import/ as the API reference. Keep the
beta warning until qualification.

Expand Down
19 changes: 19 additions & 0 deletions tests/fixtures/migration-bundle-v1/mysql-dump.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"bundleVersion": 1,
"bundleCreatedAt": "2026-09-14T12:00:00.000Z",
"sourceInstallType": "production",
"kind": "mysql-dump",
"ghost": {
"version": "6.2.0"
},
"url": "https://example.com",
"adminUrl": "https://admin.example.com",
"database": {
"path": "database.sql"
},
"content": "content/",
"config": {
"mail__from": "Ghost Blog <noreply@example.com>",
"mail__options__auth__pass": "pa$$word \\\" #\n"
}
}
20 changes: 20 additions & 0 deletions tests/fixtures/migration-bundle-v1/portable.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"bundleVersion": 1,
"bundleCreatedAt": "2026-09-14T12:00:00.000Z",
"sourceInstallType": "local",
"kind": "portable",
"ghost": {
"version": "6.2.0"
},
"url": "https://example.com",
"adminUrl": "https://admin.example.com",
"database": {
"path": "content/data/content.json",
"members": "content/data/members.csv"
},
"content": "content/",
"config": {
"mail__from": "Ghost Blog <noreply@example.com>",
"mail__options__auth__pass": "pa$$word \\\" #\n"
}
}