Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7997c92d | ||
|
|
cf21e199bc | ||
|
|
b5fc43d208 | ||
|
|
6dc6af6359 | ||
|
|
e0e2e1e7b5 | ||
|
|
39d42119b2 | ||
|
|
66438836de | ||
|
|
3ad055d4a8 | ||
|
|
dcf9c13455 | ||
|
|
425c7b8e20 | ||
|
|
d61de6f3f7 | ||
|
|
cc40c23ea8 | ||
|
|
4aeff39756 | ||
|
|
0e597c5329 | ||
|
|
41e61fc148 | ||
|
|
553d9e4759 | ||
|
|
a35eb4f288 | ||
|
|
db50b84bfd |
@@ -134,6 +134,8 @@ After updating, also create the new trip's page tree under `user/pages/01.trips/
|
|||||||
|
|
||||||
Always use `make` commands for anything on the production server (`make remote-install-plugins`, `make remote-clean`, etc.) — never SSH directly since credentials live in `.env`. If a remote operation isn't covered by an existing `make` command, either ask the user to run it manually or suggest adding a new `make` command if it seems reusable.
|
Always use `make` commands for anything on the production server (`make remote-install-plugins`, `make remote-clean`, etc.) — never SSH directly since credentials live in `.env`. If a remote operation isn't covered by an existing `make` command, either ask the user to run it manually or suggest adding a new `make` command if it seems reusable.
|
||||||
|
|
||||||
|
For a full upgrade/deploy through local → test → prod (ordered steps, smoke checklist, rollback), follow the runbook at [`docs/guides/deploy-cycle.md`](docs/guides/deploy-cycle.md).
|
||||||
|
|
||||||
### Content sync
|
### Content sync
|
||||||
|
|
||||||
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
|
- `make content-push` — commit and push `user/` to Gitea (triggers production pull via webhook)
|
||||||
@@ -171,15 +173,55 @@ Active settings in `user/config/system.yaml`:
|
|||||||
|
|
||||||
With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file.
|
With these settings, Grav rebuilds templates on every request. This is intentionally slower but means you never need to flush cache after editing a `.html.twig` file.
|
||||||
|
|
||||||
### Production mode (not yet configured)
|
### Production mode (per-environment override)
|
||||||
|
|
||||||
Before going live, change in `user/config/system.yaml`:
|
Production needs different Twig settings than dev, but **never change the
|
||||||
|
committed `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.
|
||||||
|
|
||||||
| Setting | Prod value | Why |
|
Instead, prod values are a **per-environment override** deployed to the server
|
||||||
|---|---|---|
|
only, via Grav's per-environment config (`environment://config`, keyed on the
|
||||||
| `twig.cache` | `true` | Templates compiled once and reused; safe because theme files don't change at runtime |
|
request hostname):
|
||||||
|
|
||||||
**Pre-launch smoke test required:** with `twig.cache: true`, submit one post via `/post` and confirm the entry appears in the trip page feed at `/trips/italy-2026-demo` immediately. This verifies the cache-on-save plugin (BUG-001 fix) works correctly with caching enabled.
|
| 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.yaml` and clears cache. It
|
||||||
|
deep-merges over the committed `system.yaml`.
|
||||||
|
- **Not synced by content:** `user/env/` is outside the content repo's tracked
|
||||||
|
folders, so `content-push` / git-sync / `remote-fetch-content` do **not**
|
||||||
|
restore it. **Re-run `make remote-apply-env-prod` after any fresh install.**
|
||||||
|
- The hostname segment defaults to `REMOTE_HOST`; override with `WEB_HOST` in
|
||||||
|
`.env.<env>` if Grav sees a different host than the SSH host.
|
||||||
|
|
||||||
|
> **⚠️ Once `user/env/<hostname>/` exists, Grav's Admin saves ALL config there.**
|
||||||
|
> Creating the env override dir has a site-wide side effect: Grav's Admin panel
|
||||||
|
> writes **every** config change (system *and* plugin) into the active
|
||||||
|
> environment's config tree — e.g. editing a plugin on prod saves to
|
||||||
|
> `user/env/intotheeast.com/config/plugins/<name>.yaml`, **not**
|
||||||
|
> `user/config/plugins/<name>.yaml`. Consequences you must remember:
|
||||||
|
> - Config edited via **Admin on the server is server-only**: `user/env/` is
|
||||||
|
> outside the content repo's tracked folders, so it is **not committed** and
|
||||||
|
> **not synced by git-sync** (which syncs only `pages`/`config`/`themes`).
|
||||||
|
> Good for secrets — `git-sync.yaml` (token) safely lives at the env path —
|
||||||
|
> but it means prod Admin config edits silently do **not** reach Gitea/local.
|
||||||
|
> - When reading/writing server config, check **both** `user/config/...` and
|
||||||
|
> `user/env/<host>/config/...` (env wins). Server tooling must search the env
|
||||||
|
> path first — see `scripts/git-sync-toggle.sh` and `make remote-diag`.
|
||||||
|
> - Repo-authored config (`user/config/...` via `make content-push`) still
|
||||||
|
> applies everywhere; the env tree only holds per-host overrides + Admin-on-
|
||||||
|
> server edits. Full details: `docs/working/git-sync-notes.md`.
|
||||||
|
|
||||||
|
**Pre-launch smoke test required:** with the prod override applied, submit one
|
||||||
|
post via `/post` and confirm the entry appears in the trip page feed
|
||||||
|
immediately. This verifies the cache-on-save plugin (BUG-001 fix) works
|
||||||
|
correctly with caching enabled.
|
||||||
|
|
||||||
### What the cache-on-save plugin handles
|
### What the cache-on-save plugin handles
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ REMOTE_PORT ?= 22
|
|||||||
SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST)
|
SSH := ssh -p $(REMOTE_PORT) $(REMOTE_USER)@$(REMOTE_HOST)
|
||||||
WEBROOT ?= $(REMOTE_HOME)/public_html
|
WEBROOT ?= $(REMOTE_HOME)/public_html
|
||||||
SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
|
SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
|
||||||
|
# Hostname Grav uses to pick its per-environment config (user/env/<host>/).
|
||||||
|
# Defaults to the SSH host; override in .env.<ENV> only if the web hostname
|
||||||
|
# Grav sees differs from the SSH host (e.g. an addon domain on a shared box).
|
||||||
|
WEB_HOST ?= $(REMOTE_HOST)
|
||||||
|
|
||||||
# ── Environment guard + generated per-env remote targets ──────────────────────
|
# ── Environment guard + generated per-env remote targets ──────────────────────
|
||||||
# Every remote-* target below gains `-test` / `-prod` variants, e.g.
|
# Every remote-* target below gains `-test` / `-prod` variants, e.g.
|
||||||
@@ -22,7 +26,8 @@ SITE_CONFIG_DIR ?= $(REMOTE_HOME)/site-config
|
|||||||
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
|
REMOTE_TARGETS := remote-env-setup remote-env-remove remote-wipe remote-install \
|
||||||
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
|
remote-fetch remote-fetch-content remote-install-plugins remote-update-plugins \
|
||||||
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
|
remote-upgrade-grav remote-git-sync-disable remote-git-sync-enable \
|
||||||
remote-content-status remote-clean remote-maintenance-on remote-maintenance-off
|
remote-content-status remote-clean remote-diag remote-apply-env \
|
||||||
|
remote-gpm-install remote-maintenance-on remote-maintenance-off
|
||||||
ENVS := test prod
|
ENVS := test prod
|
||||||
|
|
||||||
guard-env:
|
guard-env:
|
||||||
@@ -153,7 +158,7 @@ remote-fetch-content: guard-env
|
|||||||
$(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main"
|
$(SSH) "git -C $(WEBROOT)/user fetch origin main && git -C $(WEBROOT)/user sparse-checkout disable && git -C $(WEBROOT)/user reset --hard origin/main"
|
||||||
|
|
||||||
remote-install-plugins: guard-env
|
remote-install-plugins: guard-env
|
||||||
$(SSH) "cd $(WEBROOT) && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
|
$(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(shell cat plugins.txt | tr '\n' ' ') -y"
|
||||||
|
|
||||||
remote-update-plugins: guard-env
|
remote-update-plugins: guard-env
|
||||||
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
|
$(SSH) "cd $(WEBROOT) && php bin/gpm update -y && php bin/grav cache"
|
||||||
@@ -162,10 +167,10 @@ remote-upgrade-grav: guard-env
|
|||||||
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
|
$(SSH) "cd $(WEBROOT) && php bin/gpm self-upgrade -y && php bin/grav cache"
|
||||||
|
|
||||||
remote-git-sync-disable: guard-env
|
remote-git-sync-disable: guard-env
|
||||||
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' false" < scripts/git-sync-toggle.sh
|
$(SSH) "bash -s -- '$(WEBROOT)' false" < scripts/git-sync-toggle.sh
|
||||||
|
|
||||||
remote-git-sync-enable: guard-env
|
remote-git-sync-enable: guard-env
|
||||||
$(SSH) "bash -s -- '$(WEBROOT)/user/config/plugins/git-sync.yaml' true" < scripts/git-sync-toggle.sh
|
$(SSH) "bash -s -- '$(WEBROOT)' true" < scripts/git-sync-toggle.sh
|
||||||
|
|
||||||
remote-content-status: guard-env
|
remote-content-status: guard-env
|
||||||
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
|
$(SSH) "cd $(WEBROOT)/user && git status --short && echo '--- config diff ---' && git diff -- config/"
|
||||||
@@ -173,6 +178,37 @@ remote-content-status: guard-env
|
|||||||
remote-clean: guard-env
|
remote-clean: guard-env
|
||||||
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
$(SSH) "cd $(WEBROOT) && php bin/grav clearcache"
|
||||||
|
|
||||||
|
# Install a single GPM package on the server (e.g. git-sync, which is
|
||||||
|
# intentionally NOT in plugins.txt — it is remote-only).
|
||||||
|
# Usage: make remote-gpm-install-prod PKG=git-sync
|
||||||
|
remote-gpm-install: guard-env
|
||||||
|
@test -n "$(PKG)" || { echo "ERROR: set PKG=<plugin-slug>"; exit 1; }
|
||||||
|
$(SSH) "cd $(WEBROOT) && php bin/gpm index -f && php bin/gpm install $(PKG) -y && php bin/grav clearcache"
|
||||||
|
|
||||||
|
# Deploy per-environment Grav config overrides to the server's
|
||||||
|
# user/env/<WEB_HOST>/config/ tree (deep-merged over the committed config).
|
||||||
|
# Source of truth: deploy/env/$(ENV)/system.yaml (version-controlled). This
|
||||||
|
# tree is outside the content repo, so it is NOT restored by content sync —
|
||||||
|
# re-run after any fresh install.
|
||||||
|
remote-apply-env: guard-env
|
||||||
|
@test -f deploy/env/$(ENV)/system.yaml || { echo "ERROR: missing deploy/env/$(ENV)/system.yaml"; exit 1; }
|
||||||
|
@host="$${WEB_HOST:-$(REMOTE_HOST)}"; \
|
||||||
|
test -n "$$host" || { echo "ERROR: WEB_HOST/REMOTE_HOST unresolved"; exit 1; }; \
|
||||||
|
$(SSH) "mkdir -p $(WEBROOT)/user/env/$$host/config && cat > $(WEBROOT)/user/env/$$host/config/system.yaml && cd $(WEBROOT) && php bin/grav clearcache" < deploy/env/$(ENV)/system.yaml; \
|
||||||
|
echo "Applied deploy/env/$(ENV)/system.yaml -> $(WEBROOT)/user/env/$$host/config/system.yaml"
|
||||||
|
|
||||||
|
# Read-only health check: plugin install state, versions, key config, log tail.
|
||||||
|
remote-diag: guard-env
|
||||||
|
$(SSH) "cd $(WEBROOT) && \
|
||||||
|
echo '=== Grav version ==='; php bin/grav --version 2>/dev/null; \
|
||||||
|
echo '=== installed plugin versions ==='; for p in login admin2 flex-objects form api; do printf '%s: ' \"\$$p\"; grep -m1 '^version:' user/plugins/\$$p/blueprints.yaml 2>/dev/null || echo '(NOT installed)'; done; \
|
||||||
|
echo '=== what does GPM say about api? ==='; php bin/gpm info api 2>&1 | head -12; \
|
||||||
|
echo '=== api override (enabled/route/session) ==='; grep -nE '^enabled:|^route:|session_enabled:' user/config/plugins/api.yaml 2>&1; \
|
||||||
|
echo '=== per-env override present? ==='; for f in user/env/*/config/system.yaml; do echo \"\$$f:\"; cat \"\$$f\" 2>/dev/null | grep -E 'cache:|debug:|auto_reload:'; done; \
|
||||||
|
echo '=== twig cache populating? (non-empty => cache on) ==='; ls cache/twig/ 2>/dev/null | head -1 || echo '(empty)'; \
|
||||||
|
echo '=== git-sync config (secrets redacted) ==='; grep -vaiE 'password|token|secret' user/config/plugins/git-sync.yaml user/env/*/config/plugins/git-sync.yaml 2>/dev/null; \
|
||||||
|
echo '=== grav.log tail ==='; tail -8 logs/grav.log 2>/dev/null"
|
||||||
|
|
||||||
remote-maintenance-on: guard-env
|
remote-maintenance-on: guard-env
|
||||||
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
|
$(SSH) "bash -s on $(WEBROOT)" < scripts/server-maintenance.sh
|
||||||
|
|
||||||
|
|||||||
Vendored
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Production-only Grav config overrides.
|
||||||
|
#
|
||||||
|
# Deep-merged OVER the committed user/config/system.yaml via Grav's
|
||||||
|
# per-environment config mechanism: on the server this file is deployed to
|
||||||
|
# <webroot>/user/env/<hostname>/config/system.yaml
|
||||||
|
# and Grav's `environment://config` stream (keyed on the request hostname)
|
||||||
|
# layers it on top of `user://config`.
|
||||||
|
#
|
||||||
|
# These values are deliberately NOT in the committed system.yaml because they
|
||||||
|
# would break local development (see CLAUDE.md §1 — dev keeps twig.cache:false
|
||||||
|
# so theme edits take effect immediately). Prod is the only place they apply.
|
||||||
|
#
|
||||||
|
# Deploy with: make remote-apply-env-prod
|
||||||
|
# The user/env/ tree is outside the content repo's tracked folders, so it is
|
||||||
|
# NOT restored by content-push / git-sync / remote-fetch-content — re-run the
|
||||||
|
# target above after any fresh install.
|
||||||
|
twig:
|
||||||
|
cache: true
|
||||||
|
debug: false
|
||||||
|
auto_reload: false
|
||||||
|
|
||||||
|
# Compression / connection handling.
|
||||||
|
#
|
||||||
|
# This host is not FastCGI (no fastcgi_finish_request()), so Grav's shutdown
|
||||||
|
# "early connection close" falls back to emitting `Content-Encoding: identity`
|
||||||
|
# to ask the webserver not to compress. But Apache's mod_deflate compresses
|
||||||
|
# anyway and adds `Content-Encoding: gzip`, giving TWO conflicting headers —
|
||||||
|
# the browser can't decode the body and renders raw gzip bytes (a garbage
|
||||||
|
# page). Note: allow_webserver_gzip:true takes the SAME identity branch, so it
|
||||||
|
# does not help. The real fix is to disable the early-close path, so Grav never
|
||||||
|
# emits the bogus header and mod_deflate compresses cleanly (single header).
|
||||||
|
debugger:
|
||||||
|
shutdown:
|
||||||
|
close_connection: false
|
||||||
|
# Let the webserver own gzip; Grav does not compress or double-label.
|
||||||
|
cache:
|
||||||
|
gzip: false
|
||||||
|
allow_webserver_gzip: false
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 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` — stale `GRAV_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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Local (author + prove the change)
|
||||||
|
|
||||||
|
1. Make the change in the repo:
|
||||||
|
- GPM channel: `gpm.releases: stable` in `user/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** add `git-sync` (it is remote-only).
|
||||||
|
- Prod-only overrides (Twig cache/debug, `debugger.shutdown.close_connection: false`) in `deploy/env/prod/system.yaml` — **never** commit prod values into `user/config/system.yaml`.
|
||||||
|
- **Bump `GRAV_VERSION` in `.env.test` and `.env.prod`** to the target version. A stale value here installs the wrong core (an rc), which then blocks the `api` plugin and 404s admin.
|
||||||
|
2. `make build-assets` if you touched `js/src/*` (never hand-edit the bundled `js/*.js`).
|
||||||
|
3. Run the dev server (`docker compose … up`) and the Playwright suite.
|
||||||
|
4. Pre-flight assertions:
|
||||||
|
- `gpm.releases` is `stable`.
|
||||||
|
- `plugins.txt` is correct and does **not** contain `git-sync`.
|
||||||
|
- No prod Twig values leaked into the committed `system.yaml`.
|
||||||
|
5. Commit. `make content-push`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Test (the rehearsal — catch things here)
|
||||||
|
|
||||||
|
### Pre-flight
|
||||||
|
|
||||||
|
- `make remote-git-sync-disable-test` **before any content reset.** This is the safety catch for the whole window: it stops a half-migrated state (e.g. a fresh install-time `versions.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
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
Why each matters:
|
||||||
|
- **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/…` from `deploy/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 (especially `git-sync`). An empty/absent dir = reinstall (step 4). *(Do this via an ssh one-liner you run, or `make remote-diag-test`.)*
|
||||||
|
- **HTTP:** `/` → 200, `/admin` → 200, `/api/v1/pages` → 401, `/gpx-manager` → 200. Watch for the double-`Content-Encoding` garbage page (fix: `debugger.shutdown.close_connection: false` in the env override — already in `deploy/env/prod/system.yaml`).
|
||||||
|
- **Post smoke test:** submit one entry via `/post` and confirm it appears in the trip feed immediately. This proves the `cache-on-save` plugin 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-3 `strict_mode` flags a `self-upgrade` writes) back into `user/config/system.yaml`, or the next `fetch-content` reverts 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-prod` at the start, `remote-maintenance-off-prod` at the end, for a clean window.
|
||||||
|
- Confirm secrets are valid/rotated and `/env/` is gitignored **before** `remote-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 **push `user/` 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
|
||||||
|
# ── 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 is `deploy/env/<env>/`, so keep that current and re-apply with `remote-apply-env-<env>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## One-line invariants (the through-line)
|
||||||
|
|
||||||
|
1. `test` is config-identical to `prod`, run with the same targets in the same order.
|
||||||
|
2. Verify the **code layer** (`ls user/plugins/<name>/`), not just config, on every deploy.
|
||||||
|
3. Reinstall **remote-only** plugins (git-sync) explicitly — nothing else restores them.
|
||||||
|
4. `GRAV_VERSION` in `.env.<env>` and `gpm.releases: stable` are correct **before** any server GPM op.
|
||||||
|
5. Re-apply the **env override** after every install; keep `/env/` **gitignored**.
|
||||||
|
6. git-sync **off** during the window, **on** last; confirm the round-trip carries no secrets.
|
||||||
|
7. Diagnose actual state before changing config — an `ls` or `remote-diag` beats a guess.
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
---
|
||||||
|
title: "Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)"
|
||||||
|
date: 2026-07-05
|
||||||
|
module: git-sync
|
||||||
|
problem_type: architecture_pattern
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
category: architecture-patterns
|
||||||
|
applies_when:
|
||||||
|
- "Enabling bidirectional Grav git-sync (direction: both, on_save: true) so prod can push content back to Gitea"
|
||||||
|
- "Auditing whether the server can leak secrets (tokens, password hashes, signing salts) off the production host"
|
||||||
|
- "A per-install runtime-generated value is being persisted into a tracked config file that also carries functional config"
|
||||||
|
- "Deciding where a per-install secret must live so it never round-trips"
|
||||||
|
- "A config value keeps ping-ponging or re-committing itself across environments after each sync"
|
||||||
|
tags:
|
||||||
|
- git-sync
|
||||||
|
- secret-exposure
|
||||||
|
- gitignore
|
||||||
|
- grav
|
||||||
|
- popularity-salt
|
||||||
|
- config-boundary
|
||||||
|
- bidirectional-sync
|
||||||
|
- per-install-secret
|
||||||
|
- env-tree-leak
|
||||||
|
---
|
||||||
|
|
||||||
|
# Secret exposure under bidirectional Grav git-sync: gitignore is the only boundary (and the tracked-file boomerang trap)
|
||||||
|
|
||||||
|
> **Correction (2026-07-05):** An earlier version of this doc claimed the sync
|
||||||
|
> add-set was *scoped to the configured `folders`*, and concluded that
|
||||||
|
> `accounts/` and `user/env/` were "safe by construction" because they sit
|
||||||
|
> outside `pages/config/themes`. **That model is wrong and caused a live secret
|
||||||
|
> leak.** git-sync's auto-commit stages files **outside** the configured folders;
|
||||||
|
> the only reliable exclusion is `.gitignore`. The corrected model is below.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The intotheeast.com Grav site runs the `git-sync` plugin on production in
|
||||||
|
**bidirectional** mode: `direction: both`, `on_save: true`, a webhook at
|
||||||
|
`/_git-sync`, and configured `folders: pages, config, themes`. Bidirectional
|
||||||
|
means the server both *pulls* content authored elsewhere **and** *pushes*
|
||||||
|
content authored on the server (Admin edits, `/post` submissions, uploads) back
|
||||||
|
to the shared Gitea repo.
|
||||||
|
|
||||||
|
The operator's question was: **"Can I safely enable bidirectional prod → Gitea
|
||||||
|
sync without leaking secrets?"** Production holds things that must never reach a
|
||||||
|
shared repo — API tokens, JWT signing secrets, CSRF salts, the git-sync token
|
||||||
|
itself.
|
||||||
|
|
||||||
|
The dangerous, tempting answer is "the plugin only syncs `pages/config/themes`,
|
||||||
|
so anything outside those folders is safe." **This is false, and acting on it
|
||||||
|
leaked secrets.** See the incident below.
|
||||||
|
|
||||||
|
## The incident (what actually happened)
|
||||||
|
|
||||||
|
Production's git-sync auto-commit `9337003` ("(Grav GitSync) Automatic Commit
|
||||||
|
...") pushed the **entire `user/env/intotheeast.com/config/` tree** to Gitea —
|
||||||
|
including `api-private.php` (JWT secret), `security-private.php` (CSRF salt), and
|
||||||
|
`git-sync.yaml` (the sync **token + webhook secret**) — plus `accounts/mischa.yaml`
|
||||||
|
(password hash), `system.yaml`, and `post-form.md`.
|
||||||
|
|
||||||
|
**None of those paths is under the configured `folders: pages, config, themes`.**
|
||||||
|
`user/env/` is a sibling of `user/config/`, not a subfolder of it. Yet git-sync
|
||||||
|
staged and pushed them anyway. That single fact refutes the "folders scope the
|
||||||
|
add-set" model empirically: **the `folders` setting does not scope what the
|
||||||
|
auto-commit stages.** Whatever git-sync's exact `git add` invocation, the
|
||||||
|
operational truth is that its commit sweeps the whole `user/` working tree.
|
||||||
|
|
||||||
|
Remediation: disable sync → `.gitignore` `/env/` and `git rm --cached` it →
|
||||||
|
`reset --hard` prod to the gitignored state → regenerate the leaked JWT/CSRF
|
||||||
|
salts (delete the `*-private.php` files; Grav regenerates them) → **rotate the
|
||||||
|
Gitea token and webhook secret** (they were exposed in `git-sync.yaml`). Rotation
|
||||||
|
is what actually neutralizes the leak; the history rewrite is optional for a
|
||||||
|
private repo.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
The safety question reduces to one predicate — but **not** the one the original
|
||||||
|
doc used:
|
||||||
|
|
||||||
|
> **A file round-trips to the shared repo if and only if it is NOT gitignored.**
|
||||||
|
> The configured `folders` setting does **not** narrow this. Treat the
|
||||||
|
> round-trippable set as *everything under `user/` that git will track* — i.e.
|
||||||
|
> everything not matched by `user/.gitignore`.
|
||||||
|
|
||||||
|
Consequences, corrected:
|
||||||
|
|
||||||
|
1. **`.gitignore` is the only reliable boundary.** Do not rely on a file being
|
||||||
|
"outside the synced folders." If it is under `user/` and not gitignored, a
|
||||||
|
bidirectional sync can push it. Design exclusions with `.gitignore`, and
|
||||||
|
verify with `git -C user status` / `git -C user check-ignore <path>`.
|
||||||
|
|
||||||
|
2. **`accounts/` is NOT structurally excluded.** `user/accounts/*.yaml` (bcrypt
|
||||||
|
password hashes) is a *tracked* content folder and is not gitignored, so it
|
||||||
|
**does** round-trip — `accounts/mischa.yaml` was in the leak commit. If you
|
||||||
|
need an account file to stay server-local, it must be gitignored explicitly
|
||||||
|
(as `accounts/testrunner.yaml` already is). The earlier "password hashes never
|
||||||
|
leave the server" claim was wrong.
|
||||||
|
|
||||||
|
3. **The per-environment tree `user/env/<host>/` MUST be gitignored** — it is
|
||||||
|
**not** inherently safe. It holds the live git-sync token, JWT secret, and
|
||||||
|
CSRF salt (Grav writes all server-side Admin config there once the env dir
|
||||||
|
exists). Because it is not under `user/config/` people assumed it was outside
|
||||||
|
the sync scope; the incident proved it is not. It is now gitignored
|
||||||
|
(`/env/` in `user/.gitignore`, commit `6e8eadb`). Keep it that way.
|
||||||
|
|
||||||
|
> Side effect worth remembering: once `user/env/<host>/` exists, Grav's Admin
|
||||||
|
> writes **all** config changes there (system and plugin), not into
|
||||||
|
> `user/config/`. So server-side Admin edits are server-only — but "server-only"
|
||||||
|
> now depends entirely on `/env/` being gitignored, not on folder scope. When
|
||||||
|
> auditing, check **both** `user/config/...` and `user/env/<host>/config/...`
|
||||||
|
> (env wins at runtime). See `docs/working/git-sync-notes.md`.
|
||||||
|
|
||||||
|
4. **Per-install secrets go in gitignored companion files.** Grav's convention
|
||||||
|
splits a per-install secret out of the functional YAML into a sibling that is
|
||||||
|
gitignored: the JWT secret in `api-private.php`, the CSRF/nonce + rate-limit
|
||||||
|
salt in `security-private.php`, plus `security.yaml` and `versions.yaml`.
|
||||||
|
These are safe **because they are gitignored**, not because of where they sit.
|
||||||
|
|
||||||
|
Run every secret through predicate #1 (is it gitignored?) and the answer falls
|
||||||
|
out — but you must actually enumerate what is *not* gitignored, not what is
|
||||||
|
"outside the folders."
|
||||||
|
|
||||||
|
### The tracked-file boomerang (a separate trap)
|
||||||
|
|
||||||
|
Independently of the folder-scope error above, there is a second trap that the
|
||||||
|
original doc got right and that still holds: **a per-install value that a plugin
|
||||||
|
regenerates at runtime and persists into a tracked, functional config file.**
|
||||||
|
|
||||||
|
The concrete case: the `api` plugin's *popularity* feature generates
|
||||||
|
`popularity.salt` and writes it **into `user/config/plugins/api.yaml`** — a file
|
||||||
|
that also carries must-be-shared functional config. That file is tracked, so on
|
||||||
|
prod the popularity feature regenerates the salt, git-sync stages the change,
|
||||||
|
commits, and **pushes prod's salt back to the shared repo**. Another environment
|
||||||
|
pulls it, regenerates *its own* salt, pushes again. The value **ping-pongs across
|
||||||
|
installs**, producing endless noise commits.
|
||||||
|
|
||||||
|
The critical realization: **you cannot gitignore a single key inside a file that
|
||||||
|
also carries functional config.** `.gitignore` operates on whole files. `api.yaml`
|
||||||
|
must be tracked because the rest of it must be shared; therefore the salt inside
|
||||||
|
it is tracked too; therefore it boomerangs.
|
||||||
|
|
||||||
|
### The rules
|
||||||
|
|
||||||
|
For any per-install runtime-generated value, pick one of exactly three
|
||||||
|
resolutions — and do **not** reach for the fourth (stripping the line), which
|
||||||
|
cannot work under sync:
|
||||||
|
|
||||||
|
- **Isolate the value into a gitignored companion `<name>-private.php`** — the
|
||||||
|
pattern Grav uses for the JWT secret via `api-private.php`.
|
||||||
|
- **Disable the feature that generates it** (e.g. `popularity.enabled: false`).
|
||||||
|
- **Consciously accept the churn** when the value is genuinely low-stakes
|
||||||
|
(`popularity.salt` is an IP-hashing salt, not a credential).
|
||||||
|
|
||||||
|
Do **not** keep stripping the value from the tracked file — bidirectional sync
|
||||||
|
brings it right back on the next save.
|
||||||
|
|
||||||
|
### Before / after: what sticks and what doesn't
|
||||||
|
|
||||||
|
**A standalone file gitignored + untracked sticks.** For `security-private.php`
|
||||||
|
(a file that contains *only* the secret) or the whole `user/env/` tree:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C user rm --cached -r config/security-private.php # or: rm --cached -r env
|
||||||
|
printf '/config/security-private.php\n/env/\n' >> user/.gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
This works permanently. The path is no longer tracked, so the sync's add-set
|
||||||
|
skips it forever. The fix sticks because the secret owns its own gitignored path.
|
||||||
|
|
||||||
|
**A key inside a tracked functional file — stripping the line does NOT stick.**
|
||||||
|
For `popularity.salt` inside `api.yaml`, deleting just the `salt:` line and
|
||||||
|
committing looks clean locally, but Grav re-appends it at runtime and the next
|
||||||
|
sync re-commits and re-pushes it. The only durable fixes are the
|
||||||
|
companion-private-file pattern or disabling the feature.
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Two quiet, cross-environmental failure modes:
|
||||||
|
|
||||||
|
1. **The folder-scope illusion.** Assuming "only `pages/config/themes` sync" is a
|
||||||
|
security control leads you to leave secrets in `env/` or `accounts/` unignored
|
||||||
|
— and a single Admin save on prod pushes them to a shared repo. This actually
|
||||||
|
happened here. The only defensible mental model is *gitignore is the boundary*;
|
||||||
|
enumerate the un-ignored set, not the "un-foldered" set.
|
||||||
|
|
||||||
|
2. **The boomerang.** A "cleanup" commit that strips a secret from a *tracked*
|
||||||
|
file looks done locally but silently reappears upstream on the next content
|
||||||
|
save, because the plugin regenerates it and the sync re-commits it.
|
||||||
|
|
||||||
|
Getting both right is what lets you answer "is bidirectional sync safe?" honestly.
|
||||||
|
The answer is **yes, once `user/.gitignore` actually excludes every sensitive
|
||||||
|
path** — `env/`, the per-install `*-private.php` files, `security.yaml`,
|
||||||
|
`versions.yaml`, and any account file that must stay server-local — and once every
|
||||||
|
runtime-regenerated value either lives in its own gitignored file or is a
|
||||||
|
consciously-accepted low-stakes churn. It is emphatically **not** safe on the
|
||||||
|
strength of folder scoping alone.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- **Enabling or auditing bidirectional git-sync** on a server that authors
|
||||||
|
content. Enumerate the round-trippable set as *everything under `user/` not
|
||||||
|
matched by `.gitignore`* — then confirm no secret is in it.
|
||||||
|
- **Deciding where a new secret or per-install generated value should live.**
|
||||||
|
Standalone gitignored file for anything sensitive; never a key inside a shared
|
||||||
|
functional YAML; never "outside the folders" as the sole justification.
|
||||||
|
- **Reviewing a "stop tracking this secret" cleanup** for whether it will stick:
|
||||||
|
is the secret in its own gitignored path (holds) or a line inside a tracked
|
||||||
|
functional file that something regenerates (boomerangs)?
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**The leak (what "folder scope is safe" cost).** With `folders: pages, config,
|
||||||
|
themes` and `/env/` **not** gitignored, prod's auto-commit `9337003` pushed
|
||||||
|
`user/env/intotheeast.com/config/**` (JWT, CSRF salt, git-sync token + webhook
|
||||||
|
secret), `accounts/mischa.yaml`, `system.yaml`, and `post-form.md` to Gitea —
|
||||||
|
all outside the configured folders. Fix: gitignore + untrack `/env/`, regenerate
|
||||||
|
the JWT/CSRF salts, **rotate the token and webhook secret**.
|
||||||
|
|
||||||
|
**Safe after remediation.** Same bidirectional config, but now `user/.gitignore`
|
||||||
|
excludes `/env/`, `config/plugins/git-sync.yaml`, `config/plugins/api-private.php`,
|
||||||
|
`config/security.yaml`, `config/security-private.php`, `config/versions.yaml`.
|
||||||
|
Running each secret through *is-it-gitignored*: all sensitive paths are excluded →
|
||||||
|
none is in the round-trippable set. Verified: prod's `git status` shows only the
|
||||||
|
intended tracked content, and no boomerang/secret commit lands on the remote.
|
||||||
|
|
||||||
|
**Boomerang example.** `popularity.salt` in the tracked `api.yaml` regenerates
|
||||||
|
per-install and re-commits under sync. The fix that *sticks* is the
|
||||||
|
companion-private-file pattern or `popularity.enabled: false` — **not** stripping
|
||||||
|
the `salt:` line.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the
|
||||||
|
origin of the "functional plugin config in tracked `user/config/plugins/`,
|
||||||
|
secrets/per-install values in gitignored `*-private.php`" rule. That doc covers
|
||||||
|
*where config must live to deploy*; this doc covers *why gitignore — not folder
|
||||||
|
scope — is the sync boundary, and why a runtime-written tracked value boomerangs*.
|
||||||
|
- `docs/working/git-sync-notes.md` — operational notes on git-sync's synced
|
||||||
|
folders and the per-environment tree. Corrected in the same 2026-07-05 pass to
|
||||||
|
drop the "env/ is outside the sync scope so it's safe" claim.
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`
|
||||||
|
— adjacent context from the same Grav production cutover, different failure mode.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
---
|
||||||
|
title: "Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder"
|
||||||
|
date: 2026-07-04
|
||||||
|
category: docs/solutions/conventions
|
||||||
|
module: "grav / plugin configuration"
|
||||||
|
problem_type: convention
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
applies_when:
|
||||||
|
- "Editing functional config for any GPM-managed Grav plugin"
|
||||||
|
- "user/plugins/ is gitignored and only pages/config/accounts/themes are tracked"
|
||||||
|
- "Preparing a fresh install or production cutover"
|
||||||
|
- "A plugin behaves correctly locally but ships with only default config on deploy"
|
||||||
|
related_components:
|
||||||
|
- "grav"
|
||||||
|
- "gpm"
|
||||||
|
- "api plugin"
|
||||||
|
- "content repo"
|
||||||
|
- "deployment"
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- plugin-config
|
||||||
|
- gpm
|
||||||
|
- config-override
|
||||||
|
- gitignore
|
||||||
|
- deployment
|
||||||
|
- api-plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav plugin config must live in the tracked user/config/plugins/ override, not the plugin folder
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Grav resolves a plugin's config by deep-merging two layers: the plugin's own shipped file `user/plugins/<name>/<name>.yaml` (installed by GPM, part of the package) and the tracked override `user/config/plugins/<name>.yaml` (which wins). In this project the content repo tracks only `pages/`, `config/`, `accounts/`, `themes/`; `user/plugins/` and `user/data/` are gitignored (GPM manages plugin *code*). So any functional config a developer edits into a plugin's own `user/plugins/<name>/<name>.yaml` is invisible to version control.
|
||||||
|
|
||||||
|
It was this gap that left the `api` plugin unconfigured on the fresh prod install. Its `enabled`/`route`/`session_enabled`/cors/rate_limit config existed only in the untracked plugin folder locally, while the committed `user/config/plugins/api.yaml` held only a runtime `popularity.salt`. The local machine worked because the plugin folder had been hand-edited; every fresh environment got only the plugin's shipped defaults.
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
Put **functional** plugin configuration in the TRACKED override `user/config/plugins/<name>.yaml`. Grav deep-merges it over the plugin's shipped defaults, so it need only carry the keys that must differ (or the full config, for clarity). Keep **secrets and per-install generated values** OUT of the tracked file — JWT secrets, salts, encrypted tokens belong in gitignored `*-private.php` companion files (e.g. `api-private.php`, `security-private.php`) or should be regenerated per-install.
|
||||||
|
|
||||||
|
Never rely on edits to the plugin's own `user/plugins/<name>/<name>.yaml`: it is gitignored (won't deploy) and is overwritten on the next `php bin/gpm update`.
|
||||||
|
|
||||||
|
Concrete before/after, using the `api` plugin:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# WRONG: user/plugins/api/api.yaml (gitignored, GPM-managed, wiped on update)
|
||||||
|
enabled: true
|
||||||
|
route: /api
|
||||||
|
auth:
|
||||||
|
session_enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# RIGHT: user/config/plugins/api.yaml (tracked, deploys, survives gpm update)
|
||||||
|
enabled: true
|
||||||
|
route: /api
|
||||||
|
version_prefix: v1
|
||||||
|
auth:
|
||||||
|
session_enabled: true
|
||||||
|
# JWT secret intentionally NOT here — it lives in the gitignored api-private.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why This Matters
|
||||||
|
|
||||||
|
Reproducible deploys: a fresh clone or `make remote-install-<env>` must produce a working site from the repo alone. Config stranded in the gitignored plugin folder silently yields a plugin with only its shipped defaults on every new environment — which, for a plugin whose behavior depends on non-default config, means it's misconfigured or effectively off. On prod the `api` plugin's route/auth simply didn't work.
|
||||||
|
|
||||||
|
The failure is silent and per-environment: it works on the developer's machine (where the plugin folder was hand-edited) and breaks everywhere else. `gpm update` compounds it by wiping the folder edit even locally, so the "working" state is not just unshared — it is also unstable on the one machine that had it.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
- Any time you configure a Grav plugin whose non-default settings must work on a server (prod/test) or survive a plugin update.
|
||||||
|
- Especially for plugins whose function depends on config: `api` (route/auth/cors), `admin2`, `flex-objects`, form/media settings, etc.
|
||||||
|
- When auditing a fresh-install failure: check whether the "working" local config actually lives in a tracked path (`git ls-files user/config/plugins/<name>.yaml`) or was stranded in `user/plugins/<name>/`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- **api plugin (this project):** the functional config was moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`, then `make content-push` + `make remote-fetch-content-<env>` deployed it. The JWT secret stayed in the gitignored `api-private.php`.
|
||||||
|
- **Quick audit command:** `git -C user ls-files config/plugins/` shows exactly which plugin configs are tracked/deployable; anything you rely on that isn't listed is a latent fresh-install failure.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — the config gap documented here was the *other* latent problem surfaced in that same investigation: the `api` plugin also had to be *installed* first before any config could take effect. The install gap (GPM version floor) and this config-tracking gap compounded each other on the fresh prod environment.
|
||||||
|
- `docs/working/git-sync-notes.md` — the related third config location: on prod, Grav Admin saves config into the per-environment tree `user/env/<host>/config/`, which is *also* untracked. Same "config that doesn't reach the repo" family.
|
||||||
|
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — the sync-boomerang consequence of this rule: a per-install value that a plugin regenerates into a *tracked* functional config file (e.g. `popularity.salt` in `api.yaml`) re-commits itself and ping-pongs across environments under bidirectional git-sync. The `*-private.php` companion pattern this doc establishes is exactly the durable fix.
|
||||||
|
- **CLAUDE.md §0 (plugin-management model):** only `pages/`, `config/`, `accounts/`, `themes/` are tracked in the `user/` repo; `plugins/` and `data/` are gitignored and GPM-managed. That tracking boundary is exactly why functional config must live under `config/plugins/`, not in the plugin's own folder.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
title: "Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)"
|
||||||
|
date: 2026-07-04
|
||||||
|
category: docs/solutions/integration-issues
|
||||||
|
module: "grav / production deploy"
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "Homepage and every dynamic Grav page render as full-screen binary/mojibake garbage in the browser"
|
||||||
|
- "curl without Accept-Encoding returns clean HTML, so the page looks fine from a naive curl"
|
||||||
|
- "curl -H \"Accept-Encoding: gzip\" (a browser-style request) returns raw gzip bytes"
|
||||||
|
- "Response carries two conflicting headers: content-encoding: identity AND content-encoding: gzip"
|
||||||
|
- "Only appeared after switching prod to production Twig mode (twig.debug: false)"
|
||||||
|
root_cause: config_error
|
||||||
|
resolution_type: config_change
|
||||||
|
related_components:
|
||||||
|
- "Apache mod_deflate"
|
||||||
|
- "DirectAdmin shared host"
|
||||||
|
- "Grav shutdown handler (system/src/Grav/Common/Grav.php)"
|
||||||
|
- "deploy/env/prod/system.yaml"
|
||||||
|
- "make remote-apply-env-prod"
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- content-encoding
|
||||||
|
- gzip
|
||||||
|
- mod-deflate
|
||||||
|
- fastcgi-finish-request
|
||||||
|
- apache
|
||||||
|
- production-deploy
|
||||||
|
- twig-debug
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav garbage page from two conflicting Content-Encoding headers (identity + gzip)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Grav renders every dynamic page as binary garbage in the browser because it emits two conflicting `Content-Encoding` headers. The response body is valid gzip, but because the server advertises both `content-encoding: identity` and `content-encoding: gzip`, the browser cannot decide how (or whether) to inflate it, and paints the raw compressed bytes to screen. Static assets are unaffected — only Grav's own dynamically generated pages are broken. The problem surfaced only after switching the site to production Twig mode (`twig.debug: false`).
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- The homepage and all dynamic Grav pages show a full screen of binary/mojibake characters in the browser (completely unreadable). The surrounding HTML shell and static assets are fine.
|
||||||
|
- A naive `curl https://site/` (with **no** `Accept-Encoding` header) returns clean, correct HTML — so a quick curl sanity check looks perfectly healthy and completely hides the bug.
|
||||||
|
- A browser-style request exposes it. `curl -H "Accept-Encoding: gzip" -D - -o /dev/null https://site/` shows **two** `Content-Encoding` response headers:
|
||||||
|
```
|
||||||
|
content-encoding: identity
|
||||||
|
content-encoding: gzip
|
||||||
|
```
|
||||||
|
The body is valid gzip and `gunzip`s to the correct HTML.
|
||||||
|
- A static asset served by the webserver alone (e.g. a CSS/JS file) shows a **single** clean `content-encoding: gzip` under the same request — confirming the webserver's gzip is fine and the duplication is Grav-originated.
|
||||||
|
- The bug only appeared after switching the site to production Twig mode (`twig.debug: false`), which activates Grav's full shutdown/output path.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
- **First fix attempt: `cache.gzip: false` + `allow_webserver_gzip: true`.** This was the key dead end. It had **no effect** — the duplicated headers were unchanged. Reading Grav's source explained why: the branch that emits the bogus header fires on `if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip'))`. Setting `allow_webserver_gzip: true` satisfies the **same** `||` condition, so Grav still takes the identical `header('Content-Encoding: identity')` code path. The two knobs that *look* like they control this are both on the wrong side of the problem.
|
||||||
|
- **Verifying with a naive `curl` (no `Accept-Encoding: gzip`).** This hid the problem entirely, because the server only compresses when the client advertises gzip support. Any healthcheck that omits `Accept-Encoding: gzip` reports a false "all clear." Reproduce it the way a browser does — send `Accept-Encoding: gzip`, or take a real headless-browser (Playwright) screenshot.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Root the fix in Grav's shutdown handler, `system/src/Grav/Common/Grav.php` (~lines 615–631):
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($config->get('system.debugger.shutdown.close_connection', true)) {
|
||||||
|
$success = function_exists('fastcgi_finish_request') ? @fastcgi_finish_request() : false;
|
||||||
|
if (!$success) {
|
||||||
|
if (!ini_get('zlib.output_compression')) {
|
||||||
|
if ($config->get('system.cache.gzip') || $config->get('system.cache.allow_webserver_gzip')) {
|
||||||
|
header('Content-Encoding: identity'); // <-- the bogus header
|
||||||
|
} elseif (function_exists('apache_setenv')) {
|
||||||
|
@apache_setenv('no-gzip', '1');
|
||||||
|
} else {
|
||||||
|
header('Content-Encoding: none');
|
||||||
|
}
|
||||||
|
header('Content-Length: ' . ob_get_length());
|
||||||
|
}
|
||||||
|
header('Connection: close');
|
||||||
|
ob_end_flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The entire problematic block is gated by `system.debugger.shutdown.close_connection` (default `true`). Disable it so the whole branch is skipped and Grav never touches `Content-Encoding` at all.
|
||||||
|
|
||||||
|
In this project it is applied as a **per-environment (prod-only) override** so local dev is untouched — `deploy/env/prod/system.yaml`, deployed via `make remote-apply-env-prod`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
debugger:
|
||||||
|
shutdown:
|
||||||
|
close_connection: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify — the response must show **exactly one** `content-encoding`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -D - -o /dev/null -H "Accept-Encoding: gzip" https://site/ | grep -i content-encoding
|
||||||
|
# content-encoding: gzip
|
||||||
|
```
|
||||||
|
|
||||||
|
Then take a screenshot of the rendered page to confirm it displays correctly. A `curl`-without-gzip check is **not** sufficient proof — it would have passed even while the bug was live.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
`debugger.shutdown.close_connection` (default `true`) makes Grav flush the full response and close the connection to the browser **early**, so slow shutdown tasks (logging, debugger teardown) don't keep the visitor waiting. On a FastCGI/PHP-FPM host, Grav does this cleanly via `fastcgi_finish_request()` and never manipulates headers — which is why the bug is invisible on most stacks.
|
||||||
|
|
||||||
|
On a **non-FastCGI** host (LiteSpeed, suPHP, plain CGI), `fastcgi_finish_request()` does not exist, so `$success` is `false` and Grav falls back to closing the connection *manually*. To do that it must set an explicit `Content-Length`, and to keep that length honest it tries to tell the webserver "do not compress this body" — which, on the `cache.gzip`/`allow_webserver_gzip` branch, it expresses as `header('Content-Encoding: identity')`.
|
||||||
|
|
||||||
|
But `identity` is not a real content transformation and is **not** a recognized "suppress compression" signal to Apache `mod_deflate`. `mod_deflate` ignores it, compresses the body anyway, and appends its **own** `Content-Encoding: gzip`. The response now carries two contradictory `Content-Encoding` headers (`identity` and `gzip`). Browsers cannot reconcile the contradiction, fail to inflate the gzip stream, and render the raw compressed bytes — the "binary garbage" screen.
|
||||||
|
|
||||||
|
Setting `close_connection: false` means Grav never enters the manual connection-close path, never emits `Content-Encoding: identity`, and leaves the webserver as the **sole** authority on compression. The webserver then sends a single, correct `Content-Encoding: gzip`, and the browser inflates and renders normally.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- On **non-FastCGI PHP hosts with server-side gzip** (Apache `mod_deflate`, LiteSpeed), set `debugger.shutdown.close_connection: false` for that environment. Deliver it as a **per-environment override**, never by editing the committed `system.yaml` (which would silently change dev behavior too).
|
||||||
|
- **Reproduce compression bugs the way a browser sees them.** Always test with `curl -H "Accept-Encoding: gzip" -D -` and/or a headless-browser screenshot. A plain `curl` negotiates no compression and silently masks encoding bugs.
|
||||||
|
- **Health check:** dynamic pages must return **exactly one** `Content-Encoding` header. Two of them (`identity` + `gzip`) is the unambiguous signature of this bug. Add this assertion to any smoke test.
|
||||||
|
- **Know the trigger.** This can stay completely hidden in development mode and only appear once a site is switched to production mode (`twig.debug: false`), which activates Grav's full shutdown/output path. Re-run the browser-style compression check as part of any production cutover.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- **Grav per-environment override mechanism** — `deploy/env/prod/system.yaml` applied via `make remote-apply-env-prod`, described in `CLAUDE.md` §1 ("Production mode — per-environment override"). The pattern that lets a prod-only setting like `debugger.shutdown.close_connection: false` ship without mutating the committed dev `system.yaml`.
|
||||||
|
- **`docs/working/git-sync-notes.md`** — documents the `user/env/<hostname>/config/` override tree (where this fix physically lives on the server) and the caveat that config saved via Admin on the server stays server-only.
|
||||||
|
- **`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`** — sibling from the same cutover: Admin2 login failed because a stale `GRAV_VERSION` installed an rc core and GPM wouldn't serve the `api` plugin. Different root cause, same deploy.
|
||||||
|
- **`docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md`** — a sibling gotcha from the same 2026-07-04 Grav 2.0.4 production cutover (account permission provisioning); different root cause, same deploy.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
title: "Grav plugin won't enable because its code is missing while its config persists in the env tree"
|
||||||
|
date: 2026-07-05
|
||||||
|
category: integration-issues
|
||||||
|
module: git-sync
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: tooling
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "git-sync plugin will not enable on prod despite enabled: true in its config"
|
||||||
|
- "Plugin does not appear / cannot be toggled on in the Grav Admin UI"
|
||||||
|
- "Config-level fix attempts (editing plugin YAML) have no effect"
|
||||||
|
- "make remote-gpm-install-prod PKG=git-sync reports a FRESH install, not 'already installed'"
|
||||||
|
root_cause: incomplete_setup
|
||||||
|
resolution_type: dependency_update
|
||||||
|
related_components:
|
||||||
|
- documentation
|
||||||
|
- development_workflow
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- git-sync
|
||||||
|
- gpm
|
||||||
|
- plugin-management
|
||||||
|
- env-config
|
||||||
|
- config-without-code
|
||||||
|
- troubleshooting-order
|
||||||
|
- remote-only-plugin
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grav plugin won't enable because its code is missing while its config persists in the env tree
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
After remediating an unrelated git-sync secret leak on prod (intotheeast.com, Grav 2.0.7 on a DirectAdmin/Apache shared host), the operator went to re-enable the `git-sync` plugin. Setting `enabled: true` in its config had no effect: the plugin would not appear as enabled in the Grav Admin plugins UI, and toggling it on manually in the browser did not take either. It looked fully "configured" — the config file was right there — but the plugin was inert and sync never ran.
|
||||||
|
|
||||||
|
The trap is that the plugin's config file existed (in the per-host env tree at `user/env/intotheeast.com/config/plugins/git-sync.yaml`), so any inspection that reads only config concluded the plugin was present and just needed enabling. The actual problem was one layer down: the plugin's **code** was missing from `user/plugins/git-sync/`. A Grav plugin cannot load or enable without its code on disk, no matter what its config says.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- The git-sync plugin does not appear as enabled, and cannot be enabled, in the Grav Admin UI — despite `enabled: true` being present in its config.
|
||||||
|
- Toggling `enabled` in config, or flipping the toggle in the Admin UI, produces no working plugin. Sync does not run.
|
||||||
|
- The plugin's config file DOES exist (in the per-environment tree `user/env/intotheeast.com/config/plugins/git-sync.yaml`), so the plugin appears "present" whenever only the config is inspected — masking the real state.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
1. **Setting / ensuring `enabled: true` in the git-sync config.** No effect. Config was never the problem.
|
||||||
|
2. **Enabling the plugin manually in the Admin UI.** The toggle wouldn't take.
|
||||||
|
|
||||||
|
Both failed attempts operate on the **config** layer. But the plugin's **code** was absent from `user/plugins/git-sync/`, and Grav can't load a plugin without its code. Grav (and any tooling that reads the config tree) reports a plugin as "configured" purely from the presence of its config file, which masks the absence of code. Diagnosing and poking at the config layer could never fix a missing-code problem — and guessing at config changes before running a simple `ls` on the plugin directory cost real time here.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
First, run the decisive diagnostic on the server — confirm whether the plugin code actually exists *before* touching config:
|
||||||
|
|
||||||
|
```
|
||||||
|
ls -la $WEBROOT/user/plugins/git-sync/ # empty/absent => missing code, reinstall
|
||||||
|
```
|
||||||
|
|
||||||
|
(In this project, do that via a make target or an ssh one-liner the user runs — never raw SSH by the assistant. All server ops go through `make remote-*`.)
|
||||||
|
|
||||||
|
With the directory confirmed empty/absent, reinstall the plugin's code via GPM:
|
||||||
|
|
||||||
|
```
|
||||||
|
make remote-gpm-install-prod PKG=git-sync # GPM fresh-installs Git Sync v3.4.4
|
||||||
|
```
|
||||||
|
|
||||||
|
That make target runs, on the server:
|
||||||
|
|
||||||
|
```
|
||||||
|
php bin/gpm index -f && php bin/gpm install git-sync -y && php bin/grav clearcache
|
||||||
|
```
|
||||||
|
|
||||||
|
The install output read **"Preparing to install Git Sync [v3.4.4] ... Success!"** — a **fresh** install, not "already installed." That fresh-install line is exactly what confirmed the code had been absent all along. After the reinstall plus cache clear, the plugin enabled and sync worked.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
Grav resolves a plugin from **two independent locations**:
|
||||||
|
|
||||||
|
- **Code** at `user/plugins/<name>/` — installed by GPM. Note `user/plugins/` is gitignored (`/plugins/*`) and is NOT tracked by the content repo.
|
||||||
|
- **Config** — the tracked `user/config/plugins/<name>.yaml` and/or the per-host `user/env/<host>/config/plugins/<name>.yaml`.
|
||||||
|
|
||||||
|
These two can **desync**: config can exist with no code behind it. Config alone makes the plugin look present to any tool that only reads config, but the plugin stays inert until its code is on disk. GPM reinstall restores the code; `clearcache` makes Grav re-scan and pick it up.
|
||||||
|
|
||||||
|
A project-specific amplifier made this worse: `git-sync` is a **remote-only, GPM-managed** plugin. It is deliberately NOT in `plugins.txt`, so `make install-plugins` and the normal `make remote-install` flow do **not** restore it. Only an explicit `php bin/gpm install git-sync` (via `make remote-gpm-install-prod PKG=git-sync`) does. So when its code goes missing, it does not self-heal through the standard install path — you must reinstall it explicitly.
|
||||||
|
|
||||||
|
How the code went missing here is **unconfirmed**. It happened around the git-sync secret-leak remediation, but the exact step that wiped `user/plugins/git-sync/` was not established — don't assume a specific cause.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- **Check the code layer before the config layer.** When a Grav plugin "won't enable" and config toggles do nothing, FIRST verify the code exists: `ls user/plugins/<name>/` on the server. Config-without-code is the failure class; the empty directory is the tell.
|
||||||
|
- **Enumerate both layers in all locations when diagnosing.** Plugins have a code layer (`user/plugins/<name>/`) and a config layer, and on prod the config can live in the env tree (`user/env/<host>/config/plugins/<name>.yaml`) and persist completely independently of the code. Remember: once `user/env/<host>/` exists, Grav Admin writes ALL config there, so always check both `user/config/...` and the env path (env wins).
|
||||||
|
- **Know which plugins are remote-only.** The 3-category model: GPM-via-`plugins.txt` (admin2 / api / flex-objects), custom-in-repo (cache-on-save / story-blocks), and remote-only (git-sync — never in `plugins.txt`). Remote-only plugins are NOT restored by the standard install/content flows, so reinstall them explicitly via GPM after any operation that could have wiped `user/plugins/`.
|
||||||
|
- **Diagnose actual state before proposing config fixes.** An `ls` is cheaper than a guess. Establishing that the code was missing would have pointed straight at the reinstall instead of a round of config poking.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` — **closest sibling.** Same family (a plugin non-functional on prod, resolved by a GPM install + cache clear), same 2026-07-04/05 cutover context, same `plugins.txt` / `make remote-*` / GPM machinery. **Distinct trigger:** there, GPM refuses to *offer* the plugin because the installed core is below the version floor; here, the plugin's *code folder is simply missing* while its config persists (config-without-code desync). Two different ways a plugin ends up absent/inert on prod.
|
||||||
|
- `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` — same module (git-sync) and explains **why the config survived without code**: Grav Admin writes `git-sync.yaml` into the per-environment tree `user/env/<host>/config/plugins/`, which is untracked/gitignored and not part of the plugin package. The orphaned config here is the flip side of that env-tree behavior.
|
||||||
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — establishes the plugin **code (GPM/gitignored) vs config (tracked override / env tree)** split that this bug exploits. This doc is a concrete failure of that split going the other way: config present (in the env tree), code absent.
|
||||||
|
- `docs/working/git-sync-notes.md` — operational notes on git-sync's per-environment tree, where `git-sync.yaml` lives server-only. Context for where the orphaned config resided.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
title: "Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor"
|
||||||
|
date: 2026-07-04
|
||||||
|
category: docs/solutions/integration-issues
|
||||||
|
module: "grav / production deploy / plugin install"
|
||||||
|
problem_type: integration_issue
|
||||||
|
component: authentication
|
||||||
|
severity: high
|
||||||
|
symptoms:
|
||||||
|
- "Admin2 login at /admin silently fails: button disables then re-enables, no visible error, nothing written to grav.log"
|
||||||
|
- "admin2 SPA background login POST to /api/... returns 404"
|
||||||
|
- "/api/v1/pages returns 404 on prod but 401 locally (api plugin route not registered)"
|
||||||
|
- "user/plugins/api directory does not exist on prod (plugin never installed)"
|
||||||
|
- "gpm install api reports 'These packages were not found on Grav: api' even after gpm index -f"
|
||||||
|
root_cause: config_error
|
||||||
|
resolution_type: environment_setup
|
||||||
|
related_components:
|
||||||
|
- "gpm"
|
||||||
|
- "admin2 plugin"
|
||||||
|
- "api plugin"
|
||||||
|
- "scripts/server-install.sh"
|
||||||
|
- "Makefile remote targets"
|
||||||
|
- ".env.prod"
|
||||||
|
tags:
|
||||||
|
- grav
|
||||||
|
- gpm
|
||||||
|
- admin2
|
||||||
|
- api-plugin
|
||||||
|
- plugin-dependency
|
||||||
|
- version-compatibility
|
||||||
|
- production-deploy
|
||||||
|
- env-config
|
||||||
|
---
|
||||||
|
|
||||||
|
# Admin2 login fails silently because a stale GRAV_VERSION installs an rc core below the api plugin's version floor
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
On a fresh Grav production install, Admin2 login fails silently because the `api` plugin — which Admin2 authenticates through — never installed. GPM refused to serve it: a stale `GRAV_VERSION` in `.env.prod` had installed Grav `2.0.0-rc.10`, and the `api` plugin requires Grav core `>=2.0.4`. GPM filters offered packages by the installed core version, so on an rc.10 core the `api` plugin was excluded from results entirely and reported as "not found." Admin2 was present (and depends on `api`), but its login POST hit an `/api/...` route that was never registered, so authentication silently 404'd before it ever reached Grav's auth layer.
|
||||||
|
|
||||||
|
## Symptoms
|
||||||
|
|
||||||
|
- Admin login at `/admin` silently fails: the login button disables briefly, re-enables, and shows no error. **Nothing appears in `logs/grav.log`** — a wrong password *would* log a failed-attempt warning, so its absence means auth was never reached.
|
||||||
|
- The Admin2 SPA's background login request (to an `/api/...` endpoint) returns **HTTP 404** with `content-type: application/json`.
|
||||||
|
- `GET /api/v1/pages` returns **404** on prod, but **401 Unauthorized** on the working local install — i.e. the api route isn't registered on prod at all.
|
||||||
|
- `ls user/plugins/api` on the server: **No such file or directory** — the plugin was never installed, even though `admin2` (which depends on it) was.
|
||||||
|
- `php bin/gpm install ... api -y` → `"These packages were not found on Grav: api"`, even after `php bin/gpm index -f`.
|
||||||
|
|
||||||
|
## What Didn't Work
|
||||||
|
|
||||||
|
- **Committing/deploying the api plugin config** (`enabled` / `route` / `session_enabled`, moved from the untracked `user/plugins/api/api.yaml` into the tracked `user/config/plugins/api.yaml`). This was a real, necessary fix for a *different* latent problem, but it did not fix login: you cannot configure a plugin that isn't installed. Still 404.
|
||||||
|
- **Forcing a GPM index refresh** (`php bin/gpm index -f`). No effect. "Package not found" here is not a stale-index problem — GPM filters the packages it offers by the installed Grav **core** version, and rc.10 is below the api plugin's `>=2.0.4` requirement, so `api` is excluded from results entirely.
|
||||||
|
- **Assuming "same channel = same availability."** Local (Grav 2.0.4, `stable` channel) found `api` via `gpm info api`; prod (also `stable`) reported it "not found." The channel was identical — the difference was the Grav **core** version, which silently filtered the plugin out.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
The real cause is that prod was running the wrong Grav core. `scripts/server-install.sh` downloads `grav-admin-v${GRAV_VERSION}.zip`, and `.env.prod` still carried the stale pre-upgrade `GRAV_VERSION=2.0.0-rc.10`.
|
||||||
|
|
||||||
|
1. Upgrade the Grav core in place to stable (rc.10 → 2.0.7):
|
||||||
|
```bash
|
||||||
|
make remote-upgrade-grav-prod # php bin/gpm self-upgrade -y && php bin/grav cache
|
||||||
|
```
|
||||||
|
2. Install the plugins now that a compatible core is present (the api plugin resolves):
|
||||||
|
```bash
|
||||||
|
make remote-install-plugins-prod # php bin/gpm index -f && php bin/gpm install <plugins.txt> -y
|
||||||
|
# => "Preparing to install API [v1.0.8] ... Success!"
|
||||||
|
```
|
||||||
|
3. Clear cache, then verify the api route is live and login works:
|
||||||
|
```bash
|
||||||
|
make remote-clean-prod
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' https://site/api/v1/pages
|
||||||
|
# 401 (was 404) => plugin installed + routed
|
||||||
|
```
|
||||||
|
4. **Prevent recurrence:** update `.env.prod` to `GRAV_VERSION=2.0.4` so a future *fresh* install doesn't reinstall rc.10 (self-upgrade fixed the running server, not the env file). Keep the api plugin's functional config in the tracked `user/config/plugins/api.yaml` so it deploys on a clean clone.
|
||||||
|
|
||||||
|
## Why This Works
|
||||||
|
|
||||||
|
GPM (Grav Package Manager) only offers a plugin version whose declared Grav requirement is satisfied by the **installed core**. The `api` plugin requires Grav `>=2.0.4`; on a `2.0.0-rc.10` core there is no compatible version, so GPM reports the package as "not found" rather than a version conflict. Admin2 declares `api` as a hard dependency and performs all authentication over the api plugin's `/api/v1` JWT endpoints, so with `api` absent the login POST hits a route that doesn't exist (404) and never reaches Grav's auth layer — hence the silent failure with no `grav.log` entry. Upgrading the core to a stable `>=2.0.4` build makes GPM offer `api` again; installing it registers `/api/v1`, and Admin2's login flow succeeds.
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
- **Keep `.env.<env>` `GRAV_VERSION` current.** It is the version a *fresh* `make remote-install-<env>` bakes in; a stale value silently installs an old core. After any core upgrade, bump the env file too — self-upgrade only moves the running server. Note this is a *third* version-authority surface alongside `user/config/system.yaml` `gpm.releases` (channel) and `plugins.txt` — they must stay in sync.
|
||||||
|
- **When GPM says "package not found" for a package you know is on your channel, check the target's Grav core version first** (`php bin/grav --version` on the server, or `make remote-diag-<env>`). GPM filters by core compatibility; "not found" often means "no version compatible with your core," not "missing from the index." `gpm index -f` will not help.
|
||||||
|
- **Don't trust a top-level install "Success" to mean dependencies installed.** A fresh install can leave a plugin's declared dependency unsatisfied (here `admin2` installed but its `api` dependency didn't). Verify with `ls user/plugins/<dependency>`. The same `ls` guards a *second*, distinct way a plugin ends up non-functional: its **code folder can be missing while its config persists** (e.g. in the per-host env tree), so it looks configured but never loads. Checking `ls user/plugins/<name>` catches both the missing-dependency and the config-without-code cases — see `grav-plugin-config-without-code-wont-enable.md`.
|
||||||
|
- **Know the Admin2 ⇄ api coupling.** Admin2 authenticates via the api plugin's `/api/v1` endpoints; a missing or unrouted api plugin makes admin login fail *silently* (login POST 404s, nothing logged). A quick `curl /api/v1/pages` expecting `401` (not `404`) is a good post-deploy smoke check.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
This is one of three independent gotchas from the same **2026-07-04 Grav 2.0.4 production cutover**:
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` — sibling: garbage-rendered pages from a double `Content-Encoding` header on a non-FastCGI host. Different root cause (HTTP compression), same deploy.
|
||||||
|
- `docs/solutions/test-failures/new-user-grants-api-not-admin-on-admin2.md` — sibling: an authenticated account is denied an admin-gated page because `login new-user` auto-detect granted `api.*` but not `admin.*`. Different root cause (permission provisioning), same admin2/api area.
|
||||||
|
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the upgrade plan whose Global Constraints spell out the GPM version floors (`grav >=2.0.4`, `api >=1.0.6`) that cause the "package not found" on an rc core.
|
||||||
|
- `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md` — the *other* latent problem from this same investigation: the api plugin's functional config (`enabled` / `route` / `session_enabled`) must live in the tracked `user/config/plugins/api.yaml` to deploy at all. Necessary but not sufficient here (the plugin must be installed first), but a durable convention in its own right.
|
||||||
|
|
||||||
|
A closely-related **sibling in the "plugin absent/non-functional on prod" family** (from the 2026-07-05 follow-up, not one of the three cutover gotchas above):
|
||||||
|
|
||||||
|
- `docs/solutions/integration-issues/grav-plugin-config-without-code-wont-enable.md` — same outcome (a plugin inert on prod, fixed by a GPM install + cache clear), **different trigger**: there, GPM won't *offer* the plugin because the core is below the version floor; there, the plugin's *code folder is simply missing* while its config persists in the env tree (config-without-code desync). Same `ls user/plugins/<name>` smoke check flushes both out.
|
||||||
@@ -78,5 +78,6 @@ This site runs **Admin2 only** (the classic `admin` plugin is disabled), so auto
|
|||||||
|
|
||||||
## Related Issues
|
## Related Issues
|
||||||
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade.
|
- `docs/working/plans/2026-07-04-grav-2.0.4-upgrade.md` — the self-contained test-account infrastructure shipped alongside the Grav 2.0.4 upgrade.
|
||||||
|
- Sibling gotchas from the same 2026-07-04 Grav 2.0.4 production cutover (all surface around admin2/api but with distinct root causes): `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md` (stale `GRAV_VERSION` → rc core → GPM won't serve the `api` plugin → login 404s) and `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md` (double `Content-Encoding` header → garbage page).
|
||||||
- `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production.
|
- `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` — accounts live in the `user/` repo; the `testrunner` account is gitignored so it never reaches production.
|
||||||
- GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes.
|
- GPX manager auth model (`access.admin.login: true` frontmatter + Login plugin) — see the project's GPX manager notes.
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ Ideas and improvements not yet planned or scheduled.
|
|||||||
|
|
||||||
## Production — remaining items
|
## Production — remaining items
|
||||||
|
|
||||||
- [ ] Set `twig.cache: true` in `user/config/system.yaml` on the server (do not commit — breaks local dev)
|
- [x] Prod Twig prod-mode (`cache: true`, `debug/auto_reload: false`) — applied as a per-environment override via `make remote-apply-env-prod` (source: `deploy/env/prod/system.yaml`); committed `system.yaml` stays dev
|
||||||
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on)
|
- [ ] Smoke test: submit one post via `/post`, confirm entry appears in dailies immediately (verifies cache-on-save with twig cache on)
|
||||||
- [ ] Confirm `/post` requires login — unauthenticated visitors must not be able to post
|
- [x] Confirm `/post` requires login — verified on prod (returns the login gate to unauthenticated visitors)
|
||||||
- [ ] Register at carto.com and review terms for production traffic
|
- [ ] Register at carto.com and review terms for production traffic
|
||||||
- [ ] Japan & Korea 2026 trip page: set `date_start`, add `cover_image`, upload GPX route file(s)
|
- [ ] Update `GRAV_VERSION` in `.env.prod` to `2.0.4` (was stale `2.0.0-rc.10`; fixed on the running server via self-upgrade, but a future fresh install would repeat the RC)
|
||||||
|
- [ ] git-sync on prod: install, add encrypted token, apply `folders:` fix, enable after first content round-trip
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,46 @@
|
|||||||
# Git Sync Plugin — Setup Notes
|
# Git Sync Plugin — Setup Notes
|
||||||
|
|
||||||
## Folders YAML bug
|
## ⚠️ Config lives in the ENVIRONMENT tree, not `user/config/` (IMPORTANT)
|
||||||
|
|
||||||
The plugin UI always saves the folders field as a single comma-string:
|
Prod has a per-environment override directory `user/env/<hostname>/config/`
|
||||||
|
(created for Twig prod-mode — see CLAUDE.md §1). **A crucial Grav side effect:
|
||||||
|
once that env dir exists, the Admin panel saves ALL config changes — system and
|
||||||
|
plugin — into the active environment's config tree**, not `user/config/`.
|
||||||
|
|
||||||
```yaml
|
So on prod, `git-sync.yaml` (configured via Admin) lives at:
|
||||||
folders:
|
|
||||||
- 'pages,config,themes'
|
```
|
||||||
|
user/env/intotheeast.com/config/plugins/git-sync.yaml ← here (env tree)
|
||||||
|
user/config/plugins/git-sync.yaml ← NOT here
|
||||||
```
|
```
|
||||||
|
|
||||||
But the plugin code iterates the array expecting separate items. This causes `git status pages,config,themes` to be passed as a single path, so git sees nothing to commit and sync silently does nothing.
|
Why this matters:
|
||||||
|
|
||||||
**Fix:** Edit `user/config/plugins/git-sync.yaml` directly:
|
- **⚠️ `user/env/` is NOT safe unless gitignored — it is NOT scoped out by the
|
||||||
|
`folders` setting.** An earlier version of this note claimed `user/env/`
|
||||||
|
"never reaches Gitea" because it is outside git-sync's synced folders. **That
|
||||||
|
is wrong and caused a live secret leak (2026-07-05).** git-sync's auto-commit
|
||||||
|
stages files *outside* the configured `folders`; on prod it pushed the whole
|
||||||
|
`user/env/intotheeast.com/config/` tree — JWT secret, CSRF salt, **and the
|
||||||
|
git-sync token + webhook secret** — to Gitea. The fix was to **gitignore
|
||||||
|
`/env/`** (commit `6e8eadb`). So: prod Admin config edits stay server-only
|
||||||
|
*only because `/env/` is now gitignored*, not because of folder scope. Author
|
||||||
|
durable config in the repo, not prod Admin. Full analysis:
|
||||||
|
`docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md`.
|
||||||
|
- **Look in both places.** When inspecting/toggling server config, check
|
||||||
|
`user/config/plugins/<name>.yaml` **and**
|
||||||
|
`user/env/<host>/config/plugins/<name>.yaml` (env wins).
|
||||||
|
- **Tooling is env-path-aware.** `scripts/git-sync-toggle.sh` takes a `WEBROOT`
|
||||||
|
and searches `user/env/*/config/plugins/git-sync.yaml` first, then
|
||||||
|
`user/config/plugins/git-sync.yaml`. `make remote-git-sync-disable/enable-<env>`
|
||||||
|
and `make remote-diag-<env>` use it.
|
||||||
|
|
||||||
|
## Folders format
|
||||||
|
|
||||||
|
Older plugin versions' UI saved the `folders` field as a single comma-string
|
||||||
|
(`- 'pages,config,themes'`), which the plugin iterated as one path, so sync
|
||||||
|
silently did nothing. **git-sync v3.4.4 (installed on prod 2026-07-04) saves it
|
||||||
|
correctly** as separate list items:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
folders:
|
folders:
|
||||||
@@ -20,8 +49,39 @@ folders:
|
|||||||
- themes
|
- themes
|
||||||
```
|
```
|
||||||
|
|
||||||
Never use the Admin UI to change folders — it will rewrite the broken format.
|
If you see the comma-string form on an older version, fix it by editing
|
||||||
|
`git-sync.yaml` directly (at whichever path it lives — see above); do not
|
||||||
|
re-save folders via the Admin UI on the buggy version.
|
||||||
|
|
||||||
## Files to gitignore
|
## Per-install / secret files — must be gitignored (gitignore is the boundary)
|
||||||
|
|
||||||
`user/config/plugins/git-sync.yaml` contains an encrypted token and is server-specific. `user/config/security.yaml` contains Grav nonces/salts, also server-specific. Both are in `.gitignore` and must never be committed.
|
git-sync's auto-commit stages **everything under `user/` that is not
|
||||||
|
gitignored** — the `folders` setting does *not* scope the commit add-set (a
|
||||||
|
2026-07-05 leak proved this by pushing `user/env/**`, outside the configured
|
||||||
|
folders). So `.gitignore` — not folder scope — is the only thing keeping a
|
||||||
|
per-install or secret file off Gitea. Keep all of these gitignored in
|
||||||
|
`user/.gitignore`:
|
||||||
|
|
||||||
|
| Path | Why |
|
||||||
|
|---|---|
|
||||||
|
| `env/` | **whole per-host env tree** — holds the live git-sync token, JWT secret, CSRF salt + all server-side Admin config. Gitignored + untracked 2026-07-05 (commit `6e8eadb`) after it leaked to Gitea. NOT safe on folder scope alone. |
|
||||||
|
| `config/plugins/git-sync.yaml` | encrypted token; server-specific (also lives at env path on prod) |
|
||||||
|
| `config/plugins/api-private.php` | API JWT secret |
|
||||||
|
| `config/security.yaml` | Grav nonces/salts (legacy location) |
|
||||||
|
| `config/versions.yaml` | per-install Grav schema-migration state — differs per env (dev 2.0.4, prod 2.0.7); Grav regenerates it. Untracked 2026-07-04. |
|
||||||
|
| `config/security-private.php` | CSRF/nonce + admin rate-limit signing salt; gitignored + untracked 2026-07-05 (commit 2840018). Each env keeps its own; untracking regenerates prod's salt (one-time admin re-login). |
|
||||||
|
|
||||||
|
> **Why a key inside a *tracked* config file (e.g. `popularity.salt` in `api.yaml`) can't just be stripped** — it regenerates at runtime and boomerangs back via git-sync's `git add -A`. See `docs/solutions/architecture-patterns/git-sync-secret-exposure-and-tracked-file-boomerang.md` for the full round-trippable-set model.
|
||||||
|
|
||||||
|
## git-sync config summary (prod, 2026-07-04)
|
||||||
|
|
||||||
|
- `repository: https://git.gorinskat.nl/m038/intotheeast-com-content.git`,
|
||||||
|
`branch: main`, HTTPS + token auth (SSH is Tailscale-only).
|
||||||
|
- `sync.direction: both`, `on_save/on_delete/on_media: true` → prod Admin edits
|
||||||
|
and `/post` push to Gitea; content-repo pushes pull to prod **via webhook**
|
||||||
|
(`/_git-sync`). The webhook is configured in Gitea repo settings (same secret
|
||||||
|
as the test instance).
|
||||||
|
- **Before enabling on a fresh server**, reset the synced folders clean
|
||||||
|
(`make remote-fetch-content-<env>`) so no install-time drift (e.g. a stale
|
||||||
|
`versions.yaml`) gets pushed on the first sync. Toggle with
|
||||||
|
`make remote-git-sync-disable/enable-<env>`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Status:** ✅ Complete (2026-07-04) — local (Phase 1) validated and shipped; Task 5 (remote test-env, Phase 2) is user-gated and parked; Phase 3 (prod) is documentation-only per design. See "Known issue" below re: Form 9.1.10 filepond.
|
**Status:** ✅ Complete — Phase 1 (local), Phase 2 (test env), and **Phase 3 (production) all executed and shipped**. Phase 3 was run for real on 2026-07-05 (see the Phase 3 section for the execution outcome and the three `docs/solutions/` gotchas it produced). git-sync re-enabled on test and set up on prod. See "Known issue" below re: Form 9.1.10 filepond.
|
||||||
|
|
||||||
**Goal:** Upgrade Grav core `2.0.0-rc.10` → `2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only).
|
**Goal:** Upgrade Grav core `2.0.0-rc.10` → `2.0.4` stable and promote `admin2`/`api`/`flex-objects` to GPM management, validated on local then the remote test env (prod is documented-only).
|
||||||
|
|
||||||
@@ -533,15 +533,38 @@ Content, config, and accounts are in git, so no data restore is required — but
|
|||||||
|
|
||||||
**Tasks 3 & 4 (remote Makefile targets + server-install cleanup): shipped** (committed on this branch).
|
**Tasks 3 & 4 (remote Makefile targets + server-install cleanup): shipped** (committed on this branch).
|
||||||
|
|
||||||
**Task 5 (Phase 2, remote test-env): parked, user-gated.** Not executed. Requires the user to confirm which branch the test server tracks and that `.env.test` is ready. All `-test` make targets exist and dry-run clean.
|
**Task 5 (Phase 2, remote test-env): executed and validated (2026-07-04).** Sequence run: `remote-git-sync-disable-test` → `content-push` → `remote-fetch-content-test` → `remote-upgrade-grav-test` (core rc.10 → **2.0.7**; stable served a newer patch than the 2.0.4 floor) → `remote-update-plugins-test` (all plugins to stable) → `remote-content-status-test`. Smoke test on `https://test.intotheeast.com`: `/` (renders), `/admin` (admin2 panel; note the server routes admin at `/admin`, not `/admin2`), and `/gpx-manager` all return 200 with no Twig/PHP errors. git-sync was **re-enabled** afterward at the user's request.
|
||||||
|
|
||||||
|
**Config-drift gotcha (reconciled).** The server's `bin/gpm self-upgrade` ran Grav's schema migration, which rewrote `system.yaml` `strict_mode` from the 1.7-era `twig_compat: false` to `twig2_compat: false` + `twig3_compat: true`. The **local** upgrade never triggered this because it was a fresh Docker-image build, not a `self-upgrade` — so the repo's `system.yaml` was stale and a future `remote-fetch-content` (`reset --hard`) would have reverted the server. Fix: folded the Twig 3 flags into the repo's `user/config/system.yaml` (committed `2e32a85`, content-pushed), verified the local Grav 2.0.x container renders 200 under them, then reset the server to the new `origin/main` before re-enabling git-sync so its working tree was clean. `versions.yaml` and `accounts/.htaccess` drift is install-local and left to Grav to manage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3 — Production (fresh install, NOT executed)
|
## Phase 3 — Production (fresh install) — EXECUTED 2026-07-05
|
||||||
|
|
||||||
Production is empty, so this is a **fresh install**, not an upgrade — and it is **documentation only**. Do not run it as part of this plan.
|
> **Execution outcome (2026-07-05):** the fresh prod install was run for real
|
||||||
|
> (`make remote-install-prod`) and the site is live at `https://intotheeast.com`.
|
||||||
|
> The runbook below was followed, but three non-obvious gotchas surfaced — each
|
||||||
|
> now has its own learning in `docs/solutions/`:
|
||||||
|
> - **Stale `.env.prod GRAV_VERSION`** installed Grav rc.10, so GPM wouldn't
|
||||||
|
> serve the `api` plugin (needs ≥2.0.4) → Admin2 login 404'd silently. Fixed
|
||||||
|
> via `make remote-upgrade-grav-prod` (→ 2.0.7) + reinstall. See
|
||||||
|
> `docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`.
|
||||||
|
> **TODO: bump `.env.prod GRAV_VERSION` to `2.0.4`** so a future fresh install
|
||||||
|
> doesn't repeat the RC.
|
||||||
|
> - **Double `Content-Encoding` header** (non-FastCGI host + mod_deflate)
|
||||||
|
> rendered a garbage page once prod switched to `twig.debug: false`. Fixed via
|
||||||
|
> `debugger.shutdown.close_connection: false` in the prod env override. See
|
||||||
|
> `docs/solutions/integration-issues/grav-double-content-encoding-garbage-page.md`.
|
||||||
|
> - **Plugin config stranded in the untracked `user/plugins/`** doesn't deploy.
|
||||||
|
> See `docs/solutions/conventions/grav-plugin-config-must-be-tracked-override.md`.
|
||||||
|
>
|
||||||
|
> Twig prod-mode is applied as a per-environment override (`deploy/env/prod/system.yaml`
|
||||||
|
> via `make remote-apply-env-prod`); git-sync is installed, configured, and enabled
|
||||||
|
> (see `docs/working/git-sync-notes.md`). Remaining minor follow-ups: gitignore
|
||||||
|
> `config/security-private.php` (committed salt); optional `popularity.salt` strip.
|
||||||
|
|
||||||
When prod is provisioned:
|
The original runbook (production was empty, so this was a **fresh install**, not
|
||||||
|
an upgrade):
|
||||||
|
|
||||||
1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`.
|
1. **Provision creds:** copy the REMOTE section of `.env.example` into `.env.prod` with production values (never commit it). Run `make remote-env-setup-prod`.
|
||||||
2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt` — `admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.
|
2. **Fresh install at 2.0.4:** `make remote-install-prod` with `GRAV_VERSION=2.0.4` in `.env.prod`. `scripts/server-install.sh` installs core, then all of `plugins.txt` — `admin2`/`api`/`flex-objects` now install purely via `php bin/gpm install` (no zip-stash; that special-casing was removed in Task 4). The `gpm.releases: stable` channel arrives with the `user/` content clone.
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
FILE="$1"
|
# Enable/disable the git-sync plugin by flipping `enabled:` in its config.
|
||||||
|
#
|
||||||
|
# git-sync.yaml may live in the per-environment config tree
|
||||||
|
# (user/env/<host>/config/plugins/) when an env override dir exists — Grav's
|
||||||
|
# Admin saves config there when an environment is active — otherwise in the
|
||||||
|
# standard user/config/plugins/. Search both, env path first.
|
||||||
|
WEBROOT="$1"
|
||||||
STATE="$2"
|
STATE="$2"
|
||||||
: "${FILE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
: "${WEBROOT:?usage: git-sync-toggle.sh <webroot> <true|false>}"
|
||||||
: "${STATE:?usage: git-sync-toggle.sh <git-sync.yaml path> <true|false>}"
|
: "${STATE:?usage: git-sync-toggle.sh <webroot> <true|false>}"
|
||||||
|
|
||||||
if [ ! -f "$FILE" ]; then
|
FILE=$(ls "$WEBROOT"/user/env/*/config/plugins/git-sync.yaml \
|
||||||
echo "ERROR: $FILE not found — is git-sync installed on this server?" >&2
|
"$WEBROOT"/user/config/plugins/git-sync.yaml 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
|
||||||
|
echo "ERROR: git-sync.yaml not found under $WEBROOT — is git-sync installed/configured?" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -17,4 +26,4 @@ else
|
|||||||
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
|
printf 'enabled: %s\n' "$STATE" | cat - "$FILE" > "$FILE.tmp" && mv "$FILE.tmp" "$FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "git-sync now: $(grep -E '^enabled:' "$FILE")"
|
echo "git-sync now: $(grep -E '^enabled:' "$FILE") ($FILE)"
|
||||||
|
|||||||
+1
-1
Submodule user updated: 924cfc18e2...4aa9ae9b23
Reference in New Issue
Block a user