@@ -177,15 +177,21 @@ install doesn't cry wolf:
177177
178178**Turn `--strict-keys` on in CI.** A typo'd key is the most common config
179179mistake and the quietest : nothing loads it, and without the flag the check
180- exits 0. Pin the nerve version the workflow installs to the one you deploy, so a
181- key introduced by a newer nerve can't fail the check against an older validator.
180+ exits 0. Pin the nerve the workflow validates with to the one you deploy, so a
181+ key introduced by a newer nerve can't fail the check against an older validator
182+ — `nerve config init-repo` writes that pin in for you.
182183
183184One more flag controls *what* gets validated. `--portable-only` ignores this
184185machine's `config.yaml` and `config.local.yaml` and judges the portable
185186` <workspace>/config/settings.yaml` layer on its own. Use it when reviewing a
186187change headed for a shared repo : otherwise a local override can mask an invalid
187188shared value, and — more often — a broken *local* file fails a shared bundle
188- that has nothing wrong with it. Pass `--workspace` alongside it : with no machine
189+ that has nothing wrong with it. Cron is covered too : normally a workspace
190+ carrying no jobs falls back to the machine-local `~/.nerve/cron`, which is right
191+ for an un-migrated install and wrong here — a typo in *that* file would fail a
192+ shared bundle that doesn't contain it — so under `--portable-only` only the
193+ repo's own `config/cron/` counts, and anything skipped is named in the report.
194+ Pass `--workspace` alongside it : with no machine
189195config left to read the workspace location from, it falls back to the default
190196one, and validating the wrong tree is how a CI gate ends up green and useless.
191197` --portable-only` fails outright if it opened no file at all under the
@@ -210,15 +216,40 @@ jobs:
210216 runs-on: ubuntu-latest
211217 steps:
212218 - uses: actions/checkout@v5
213- - uses: astral-sh/setup-uv@v6
214- # Pin to the nerve version you deploy, so the validator agrees with it.
215- - run: uv pip install --system nerve # or: pip install <your nerve dist>
216- - run: nerve config validate --workspace . --portable-only --strict-keys
219+ # The validator needs the standard library and PyYAML, nothing else, so
220+ # it runs from a source checkout pinned to the ref you deploy. Nerve is
221+ # not published to PyPI — ` pip install nerve` would fetch an unrelated
222+ # package — and installing the app to lint a config file would pull in
223+ # every runtime dependency for no benefit.
224+ - uses : actions/checkout@v5
225+ with :
226+ repository : ClickHouse/nerve
227+ ref : <the nerve revision your instance runs>
228+ path : .nerve-src
229+ - uses : actions/setup-python@v5
230+ with :
231+ python-version : ' 3.12'
232+ - run : pip install pyyaml
233+ # PYTHONSAFEPATH keeps the config repo off sys.path: `python -m` would
234+ # otherwise put the working directory first, so a `yaml.py` or a `nerve/`
235+ # directory committed to this repo would be imported instead of the real
236+ # one — running code straight out of an unreviewed pull request.
237+ #
217238 # Add --assume-lockdown if any instance served by this repo is locked and
218239 # the flag comes from the environment — otherwise CI checks the unlocked
219240 # view and the lockdown checks never run. See "Lockdown" below.
241+ - env :
242+ PYTHONPATH : .nerve-src
243+ PYTHONSAFEPATH : ' 1'
244+ run : python -m nerve.config_validate --workspace . --portable-only --strict-keys
220245` ` `
221246
247+ ` nerve config init-repo` scaffolds this workflow on every PR, plus a
248+ [gitleaks](https://github.com/gitleaks/gitleaks) scan ahead of it for the one
249+ thing review is worst at catching — a credential pasted into a tracked file. See
250+ [Setting up the config repo](#setting-up-the-config-repo) for the generated file
251+ and the end-to-end setup.
252+
222253# # Hot-Reload
223254
224255Many config changes apply **without a restart** — but not all of them, and a
@@ -316,6 +347,171 @@ the same skills failure gives `ok: false` on `/api/config/reload` and
316347` applied` , not `ok`**, unless what you want to know is specifically whether the
317348merge took.
318349
350+ # # Setting up the config repo
351+
352+ The workspace *is* the config repo : its root holds `config/` (settings, cron) and
353+ ` skills/` . To put it under review and sync it to an instance, turn the workspace
354+ into a git repo with a GitHub remote, add CI validation, and (optionally) enable
355+ lockdown.
356+
357+ **1. Scaffold the repo files.** From the instance (or anywhere with the workspace
358+ checked out) :
359+
360+ ` ` ` bash
361+ nerve config init-repo # scaffolds into the resolved workspace
362+ nerve config init-repo --workspace ./ws # or an explicit path; --dry-run to preview
363+ ` ` `
364+
365+ This writes four files into the workspace (never overwriting existing ones) :
366+
367+ - ` .github/workflows/validate-config.yml` — the CI check (below)
368+ - ` .gitignore` — keeps `config.local.yaml`, `.env`, `*.migrated`, DBs, etc. out of
369+ the shared repo (secrets must never be committed — they're `${ENV_VAR}` refs)
370+ - ` README.md` — a short explainer of the PR-based flow
371+ - ` config/settings.yaml` — the commented portable settings scaffold, if the
372+ workspace doesn't already have one. An instance's workspace does; a bare
373+ directory doesn't, and the CI check below fails a repo with no `config/` at
374+ all rather than passing it, so the scaffold seeds one.
375+
376+ **2. Create the GitHub repo and push.** The command prints these — it can't run
377+ them for you (they need a repo name and auth) :
378+
379+ ` ` ` bash
380+ cd <workspace>
381+ git init && git add -A && git commit -m "Initial Nerve config"
382+ gh repo create <org>/nerve-config --private --source=. --remote=origin --push
383+ ` ` `
384+
385+ **3. CI validation.** The scaffolded workflow validates the bundle and scans for
386+ committed secrets on every PR. It needs no secrets or tokens of its own —
387+ `ClickHouse/nerve` is public — and it doesn't install nerve at all : the validator
388+ depends only on the standard library and PyYAML, so CI checks out the source and
389+ runs it in place :
390+
391+ ` ` ` yaml
392+ name: validate-config
393+
394+ # Validates the Nerve config bundle on every PR so a broken change can't be
395+ # merged and then synced onto a live instance. See docs/config.md.
396+
397+ on:
398+ pull_request:
399+ push:
400+ branches: [main]
401+
402+ permissions:
403+ contents: read
404+
405+ jobs:
406+ validate:
407+ runs-on: ubuntu-latest
408+ steps:
409+ - uses: actions/checkout@v5
410+
411+ # Backstop for the one thing review is worst at spotting: a credential
412+ # pasted into a tracked file. Secrets belong in the environment and are
413+ # referenced from settings.yaml as ${ENV_VAR}. Runs before anything else
414+ # is fetched, so it scans this repo and nothing else, and needs no token
415+ # of its own — the image is used directly because the marketplace action
416+ # asks organizations for a license key.
417+ #
418+ # Pinned for the same reason the validator below is: an argument renamed
419+ # in a later gitleaks would otherwise fail every config repo at once, with
420+ # no pull request anywhere to explain it. (From 8.19 this subcommand is
421+ # spelled ` gitleaks dir <path>`; bump the tag and the args together.)
422+ # A false positive on a high-entropy string is silenced with a trailing
423+ # `# gitleaks:allow`, or repo-wide in a .gitleaks.toml.
424+ - name : Scan for committed secrets
425+ uses : docker://ghcr.io/gitleaks/gitleaks:v8.18.4
426+ with :
427+ args : detect --no-git --source=/github/workspace --redact
428+
429+ # The validator needs only the standard library + PyYAML, so we run it
430+ # straight from a source checkout — no `pip install nerve`, and none of
431+ # nerve's runtime dependencies. ClickHouse/nerve is public, so no token
432+ # is needed.
433+ #
434+ # `ref` is pinned to the nerve revision this instance runs, which is what
435+ # makes --strict-keys below safe: an older validator rejects keys the
436+ # instance understands, and a newer one accepts keys it doesn't. Bump it
437+ # when you upgrade the instance — `nerve config init-repo` will not, it
438+ # never overwrites this file.
439+ - name : Check out the nerve validator
440+ uses : actions/checkout@v5
441+ with :
442+ repository : ClickHouse/nerve
443+ ref : <the nerve revision your instance runs>
444+ path : .nerve-src
445+
446+ - uses : actions/setup-python@v5
447+ with :
448+ python-version : ' 3.12'
449+
450+ - run : pip install pyyaml
451+
452+ # The config repo root IS the workspace (it holds config/ and skills/).
453+ #
454+ # --portable-only judges the tracked bundle on its own — no machine-local
455+ # config.yaml, and no falling back to a machine-local cron directory when
456+ # this repo carries no jobs — and fails outright if it opened no file under
457+ # config/ at all, so a layout mistake can't pass as clean.
458+ # --strict-keys makes a misspelled key block the PR rather than warn.
459+ #
460+ # PYTHONSAFEPATH keeps this checkout off sys.path. `python -m` otherwise
461+ # puts the working directory first, so a `yaml.py` or a `nerve/` directory
462+ # committed to the config repo would be imported instead of the real one —
463+ # running code out of a pull request before anyone has read it.
464+ #
465+ # Unset ${ENV_VAR} secret refs are reported as info (CI has no secrets);
466+ # add --strict-env to require them. If any instance served by this repo is
467+ # locked through ${NERVE_LOCKDOWN}, add --assume-lockdown as well, or CI
468+ # only ever checks the unlocked view that instance never loads.
469+ - name : Validate config bundle
470+ env :
471+ PYTHONPATH : .nerve-src
472+ PYTHONSAFEPATH : ' 1'
473+ run : python -m nerve.config_validate --workspace . --portable-only --strict-keys
474+ ` ` `
475+
476+ That is the file verbatim, comments included — they explain each flag where the
477+ person editing it will be looking. ` python -m nerve.config_validate` is the same
478+ code as `nerve config validate` (the CLI is just a colorized wrapper) and the two
479+ take the same flags, so running the command above locally gives the same verdict
480+ CI will. Note the *config* repo is private; only nerve itself is public.
481+
482+ **The `ref:` is the part that needs your attention.** `init-repo` pins it to the
483+ commit of the nerve source the instance runs, and says so in the comment it
484+ writes. It can only do that when nerve was installed from a git checkout, that
485+ checkout is clean, and its commit is on a remote CI can fetch — a commit no
486+ remote has would fail `actions/checkout` on *every* PR, which is worse than not
487+ pinning. When any of those doesn't hold it writes `main`, and the comment above
488+ the step says it is **not** pinned and why. Either way `init-repo` never
489+ overwrites an existing workflow, so **bump the pin by hand when you upgrade the
490+ instance**; the command will not do it for you on a re-run.
491+
492+ Gate plugins need nothing installed here : validation never loads them (see
493+ [Validating Configuration](#validating-configuration)), so their imports are
494+ never resolved in CI.
495+
496+ **4. Point the instance at the remote and enable sync.** The workspace is already
497+ a git clone of the repo, so the remote and credentials come from git itself. Turn
498+ on periodic pulls in `workspace/config/settings.yaml` :
499+
500+ ` ` ` yaml
501+ workspace_sync:
502+ enabled: true
503+ branch: main
504+ interval_minutes: 5
505+ ` ` `
506+
507+ **5. (Optional) Lock it down.** Once secrets are in the environment as `${ENV_VAR}`
508+ refs, set `lockdown : true` in `settings.yaml` so the instance only ever runs the
509+ reviewed, merged remote. See [Lockdown](#lockdown-remote-only-read-only).
510+
511+ From here the loop is : open a PR → CI validates → review + merge → the instance
512+ syncs and hot-reloads. The agent proposes its own changes the same way via the
513+ ` nerve-workspace` skill.
514+
319515# # Git-Backed Workspace Sync
320516
321517The workspace can be a git repository whose remote (on GitHub) is a shared
0 commit comments