CLAUDE.md is loaded into context on every request, so every line has a recurring cost. Applies one rule to decide what earns its place: keep what changes behaviour (rules and gotchas Claude cannot discover before it acts); extract what merely describes code (Claude reads the code anyway, and prose about code silently drifts). The four stale facts fixed in the previous commit were all in the "describes code" class -- active_trip, the Admin2 version, demo-load's scope, the gitignore list. None were rules. That is the argument for moving this material next to what it documents. Extracted (kept as pointers): - entry-map + trip-feed-col parameter contracts (56 lines) -> reference/ architecture.md "Shared partial contracts". CLAUDE.md keeps only the invariants: single map path, must assign window.tripMap/homeMap, keep trip-feed-col single-purpose, initTripStats depends on MapUtils. - Prod override runbook (49 -> 9 lines) -> guides/deploy-cycle.md "The env override tree", incl. the Twig dev/prod table and WEB_HOST. CLAUDE.md keeps the two behavioural rules: never commit prod values, and Admin on the server writes to the env tree (so check both config paths, env wins). - GPX API routes, session auth and the Blob/FormData upload gotcha -> guides/gpx-manager.md "How the manager is wired". - Trip-switch procedure -> guides/trip-switching.md. CLAUDE.md keeps the one rule that matters: never re-add pageconfig.parent to post-form.md. - Also trimmed the dev-command table and custom-plugin table added in the previous commit; both largely restated the Makefile and blueprints. Fixed the guides being pointed into, so the pointers lead to truth: - trip-switching.md instructed editing a pageconfig.parent that no longer exists -- its whole "two files must be updated together" premise was obsolete and would have reintroduced the desync it warned about. - architecture.md: Grav 2.0.4->2.0.7, Admin2 2.0.10->2.0.12, corrected the posting pipeline to show cache-on-save injecting parent before the write, added entry-actions to the custom-plugin list. - japan-korea-2026 -> denmark-2026 across guides/reference (docs/solutions keeps its historical references intact -- those are incident records). Verified: every markdown link resolves, every referenced section heading exists, and each extracted item was confirmed present in its new home. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 KiB
Upgrade & Deploy Cycle: local → test → prod
This runbook is the repeatable procedure for shipping a Grav upgrade or any server-affecting change (core version, plugins, config, theme) through the three environments. It was distilled from the 2026-07 Grav 2.0.4→2.0.7 cutover, where every production surprise traced back to one of the desyncs this procedure now forces you to check.
Governing principle: test is a full dress rehearsal of prod — same
config, same -test/-prod make targets, same order. A gotcha only gets caught
on test if test is a faithful mirror of prod. Do not shortcut test.
All server operations go through make remote-* targets (never raw SSH — the
targets build the SSH connection from .env.<env>, which must never be read
directly). Every remote-* target has -test and -prod variants; a bare
target fails via guard-env.
The mental model: three places state lives
Every failure in the reference cutover was a desync between these three layers. Before and after each deploy step, ask: are they in sync?
| Layer | Location | Synced by | Failure mode |
|---|---|---|---|
| Plugin code | user/plugins/<name>/ |
GPM only (gitignored /plugins/*) |
can vanish while config remains → plugin won't enable |
| Repo config | user/config/… |
content-push / git-sync |
holds GPM channel + is where the version floor bites |
| Host config | user/env/<host>/config/… |
nothing — server-only | not restored on fresh install; must be re-applied; must be gitignored |
Referenced gotcha docs:
docs/solutions/integration-issues/grav-plugin-config-without-code-wont-enable.md— code-vs-config desync.docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md— staleGRAV_VERSION/ version floor.docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md— gitignore is the sync boundary; env-tree leak.docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md— plugin config must live in the tracked override.
These three layers describe the servers. Locally there is a fourth: the Grav
core is baked into the Docker image (Dockerfile), not in any layer above —
so the local core upgrades by an image rebuild, never by the gpm self-upgrade the
servers use. See docs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md.
The env override tree (user/env/<host>/)
Prod needs different Twig settings than dev. These are never committed to
user/config/system.yaml — twig.cache: false and debug/auto_reload: true
are the intended dev values, and committing prod values there breaks local
development for everyone. Instead they ship as a per-environment override via
Grav's environment://config, keyed on the request hostname.
| Setting | Dev (committed) | Prod (override) | Why prod differs |
|---|---|---|---|
twig.cache |
false |
true |
Compile templates once and reuse |
twig.debug |
true |
false |
No debug functions in prod |
twig.auto_reload |
true |
false |
Don't stat templates every request |
- Source of truth:
deploy/env/prod/system.yaml(version-controlled). - Deploy:
make remote-apply-env-prod— writes it to<webroot>/user/env/<hostname>/config/system.yamland clears cache. It deep-merges over the committedsystem.yaml. - Hostname segment defaults to
REMOTE_HOST; override withWEB_HOSTin.env.<env>if Grav sees a different host than the SSH host. - Not restored by anything.
user/env/is outside the content repo's tracked folders, socontent-push/ git-sync /remote-fetch-contentdo not bring it back. Re-runmake remote-apply-env-<env>after any fresh install.
Side effect: Admin writes ALL config into the env tree
Once user/env/<hostname>/ exists, Grav's Admin saves every config change
(system and plugin) there — e.g. editing a plugin on prod writes
user/env/intotheeast.com/config/plugins/<name>.yaml, not
user/config/plugins/<name>.yaml. Consequences:
- Config edited via Admin on the server is server-only: the env tree is not
committed and not synced by git-sync (which syncs only
pages/config/themes), so prod Admin edits silently never reach Gitea or local. This is good for secrets —git-sync.yaml(token), the JWT and CSRF salt safely live there — but it means config drift is invisible to the repo. - When reading or writing server config, check both
user/config/…anduser/env/<host>/config/…(env wins). Server tooling must search the env path first — seescripts/git-sync-toggle.shandmake remote-diag. - Repo-authored config (
user/config/…viamake content-push) still applies everywhere; the env tree holds only per-host overrides + Admin-on-server edits.
Full details: docs/working/git-sync-notes.md.
Phase 0 — Local (author + prove the change)
- Make the change in the repo:
- GPM channel:
gpm.releases: stableinuser/config/system.yaml(authoritative; reaches servers via content pull, so it must be right before any server GPM op). plugins.txt— the GPM-managed set only. Never addgit-sync(it is remote-only).- Prod-only overrides (Twig cache/debug,
debugger.shutdown.close_connection: false) indeploy/env/prod/system.yaml— never commit prod values intouser/config/system.yaml. - Bump
GRAV_VERSIONin.env.testand.env.prodto the target version. A stale value here installs the wrong core (an rc), which then blocks theapiplugin and 404s admin. This governs fresh remote installs only. - If the core version is changing, upgrade the local dev core too so you prove the change against the target version — bump the hardcoded
grav-admin-v<ver>.zipURL inDockerfile,docker compose build grav, thendocker rm -f intotheeast_grav && docker compose up -d grav. The local core is baked into the image, so.env GRAV_VERSIONdoes not touch it and an in-containergpm self-upgradeis non-durable. Seedocs/solutions/tooling-decisions/upgrade-local-grav-core-rebuild-docker-image.md.
- GPM channel:
make build-assetsif you touchedjs/src/*(never hand-edit the bundledjs/*.js).- Run the dev server (
docker compose … up) and the Playwright suite. - Pre-flight assertions:
gpm.releasesisstable.plugins.txtis correct and does not containgit-sync.- No prod Twig values leaked into the committed
system.yaml.
- Commit.
make content-push.
Phase 1 — Test (the rehearsal — catch things here)
Pre-flight
make remote-git-sync-disable-testbefore any content reset. This is the safety catch for the whole window: it stops a half-migrated state (e.g. a fresh install-timeversions.yaml) from auto-committing and pushing on the first sync.
Apply — in this fixed order
make remote-fetch-content-test # 1. clean-reset synced folders to repo state
make remote-upgrade-grav-test # 2. gpm self-upgrade (rewrites schema — expect drift)
make remote-update-plugins-test # 3. gpm update the plugins.txt set (auto-applies deploy/patches/)
make remote-gpm-install-test PKG=git-sync # 4. EXPLICITLY (re)install each remote-only plugin
make remote-apply-env-test # 5. re-deploy the env override (not synced; gone after install)
make remote-warmup-test # 6. clear + warm cache — a reset deploy leaves it stale
Always finish a deploy with
remote-warmup-<env>— even a content-only deploy. Areset --hard(step 1) changes files under Grav without going through it, so the compiled-Twig/page cache is stale and the first visitor eats the recompile.remote-warmupclears the cache, then crawls the public pages (homepage + trips listing + every trip page linked from it) to pre-render them. Grav has no native warmup command — this is an HTTP crawl, so it also doubles as a smoke test (a non-2xx on any page is flagged loudly).
Why each matters:
- Step 3 re-applies
deploy/patches/*.patchautomatically (it chainsremote-apply-plugin-patches). GPM install/update lays down pristine third-party plugins, wiping local fixes to git-ignoreduser/plugins/— the patch step restores them. Content pulls (step 1) do not touchplugins/, so the patch only needs re-applying after a GPM op, not after every sync. Runmake remote-apply-plugin-patches-teststandalone if you ever GPM-install outside this sequence. Requires thepatchtool on the server. Seedeploy/patches/README.md. - Step 4 is non-optional even if git-sync "was already there" — remote-only plugins are not in
plugins.txt, so nothing in steps 1–3 restores them. If the code is missing, the plugin is inert despite valid config. - Step 5 re-writes
user/env/<host>/config/…fromdeploy/env/<env>/. The env tree is not synced by anything, so a fresh install loses it until you re-apply.
Verify (smoke checklist — this is the payoff)
- Code present, not just config:
ls user/plugins/<name>/for every expected plugin (especiallygit-sync). An empty/absent dir = reinstall (step 4). (Do this via an ssh one-liner you run, ormake remote-diag-test.) - Plugin patches applied: confirm the add-page-by-form fix survived the GPM op —
grep -c toArray user/plugins/add-page-by-form/add-page-by-form.phpshould be ≥1 (0 = pristine, re-runmake remote-apply-plugin-patches-test). Functional check: edit a journal entry and add a photo — a pristine plugin 500s on save. - HTTP:
/→ 200,/admin→ 200,/api/v1/pages→ 401,/gpx-manager→ 200. Watch for the double-Content-Encodinggarbage page (fix:debugger.shutdown.close_connection: falsein the env override — already indeploy/env/prod/system.yaml). - Post smoke test: submit one entry via
/postand confirm it appears in the trip feed immediately. This proves thecache-on-saveplugin works with prod caching on. - Config drift:
make remote-diag-test— diff server config against the repo. Fold any intended schema migration (e.g. the Twig-3strict_modeflags aself-upgradewrites) back intouser/config/system.yaml, or the nextfetch-contentreverts it.
Re-enable + prove sync
make remote-git-sync-enable-test.- Confirm a content push round-trips to the server, and that no secret/boomerang commit lands on Gitea. Verify
/env/is gitignored so the env tree (which holds the token, JWT, CSRF salt) can never enter the sync add-set.
Phase 2 — Prod (repeat identically — should be mechanical)
Run the exact same sequence with -prod targets. Because test rehearsed it,
prod holds no surprises. Differences to layer on:
- Optional:
make remote-maintenance-on-prodat the start,remote-maintenance-off-prodat the end, for a clean window. - Confirm secrets are valid/rotated and
/env/is gitignored beforeremote-git-sync-enable-prod. Re-enable git-sync last. - After a clean cutover, bump the outer-repo submodule pin to the finished
user/commit — and pushuser/before the outer repo (the superproject references a child SHA that must already exist upstream).
make remote-git-sync-disable-prod
make remote-fetch-content-prod
make remote-upgrade-grav-prod
make remote-update-plugins-prod
make remote-gpm-install-prod PKG=git-sync
make remote-apply-env-prod
make remote-warmup-prod # clear + warm cache; also HTTP-smokes public pages
# ── smoke checklist (same as test) ──
make remote-git-sync-enable-prod
For a first-time / from-scratch prod bring-up, make remote-install-prod does the
full install; then still run remote-apply-env-prod and the smoke checklist, and
reinstall remote-only plugins explicitly.
Rollback & safety
- git-sync stays disabled through the whole apply window on each host — it is the catch that prevents a half-migrated state from auto-pushing.
- Content is a git repo: a bad content deploy is recoverable with
make remote-fetch-content-<env>back to a known commit. - Core + plugins are GPM-reinstallable (
remote-upgrade-grav,remote-update-plugins,remote-gpm-install PKG=…). - The one thing tooling cannot regenerate is the un-synced
user/env/<host>/tree — its source of truth isdeploy/env/<env>/, so keep that current and re-apply withremote-apply-env-<env>.
One-line invariants (the through-line)
testis config-identical toprod, run with the same targets in the same order.- Verify the code layer (
ls user/plugins/<name>/), not just config, on every deploy. - Reinstall remote-only plugins (git-sync) explicitly — nothing else restores them.
GRAV_VERSIONin.env.<env>andgpm.releases: stableare correct before any server GPM op.- Re-apply the env override after every install; keep
/env/gitignored. - git-sync off during the window, on last; confirm the round-trip carries no secrets.
- Diagnose actual state before changing config — an
lsorremote-diagbeats a guess.