Skip to content

Commit b6ab70d

Browse files
nerve config init-repo + config-repo setup runbook
Scaffolds a config repo — the tracked layout, a .gitignore that does not leak the machine-local overlay, and a CI workflow — plus a runbook for the PR-to-merge-to-sync flow. The generated CI validates the bundle without installing nerve from the repo under review, so a pull request cannot execute its own unreviewed Python in order to be judged. It enforces what the docs promise rather than a weaker subset, and the documented job matches the one actually generated instead of an idealized version of it. --portable-only now means it. Cron resolution is file-aware and would otherwise fall back to the machine-local directory, handing a run that was asked to judge the shared bundle alone a file the bundle does not contain — so a typo in a local file could fail a config repo that has nothing wrong with it. Paths outside the tracked subtree are reported as declined rather than silently substituted. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 87af896 commit b6ab70d

12 files changed

Lines changed: 1602 additions & 22 deletions

File tree

‎docs/config.md‎

Lines changed: 203 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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
179179
mistake 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

183184
One more flag controls *what* gets validated. `--portable-only` ignores this
184185
machine'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
186187
change headed for a shared repo: otherwise a local override can mask an invalid
187188
shared 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
189195
config left to read the workspace location from, it falls back to the default
190196
one, 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

224255
Many 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
317348
merge 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

321517
The workspace can be a git repository whose remote (on GitHub) is a shared

‎nerve/cli.py‎

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,27 +1277,119 @@ def config_validate(
12771277
portable_only: bool, assume_locked: bool,
12781278
) -> None:
12791279
"""Validate the configuration bundle. Non-zero exit on any error (CI-ready)."""
1280-
from nerve.config_validate import validate_config_bundle
1280+
from nerve.config_validate import render_result, validate_config_bundle
12811281

12821282
config_dir = Path(ctx.obj["config_dir"])
12831283
result = validate_config_bundle(
12841284
config_dir, workspace_override=workspace,
12851285
strict_env=strict_env, strict_keys=strict_keys,
12861286
portable_only=portable_only, assume_locked=assume_locked,
12871287
)
1288-
for msg in result.info:
1289-
click.echo(f"[info] {msg}")
1290-
for msg in result.warnings:
1291-
click.secho(f"[WARN] {msg}", fg="yellow")
1292-
for msg in result.errors:
1293-
click.secho(f"[ERR] {msg}", fg="red")
1294-
if result.ok:
1295-
click.secho("Config OK", fg="green")
1296-
else:
1297-
click.secho(f"Config invalid: {len(result.errors)} error(s)", fg="red")
1288+
# Same renderer as `python -m nerve.config_validate` (the zero-install CI
1289+
# path), just colorized here.
1290+
lines, summary = render_result(result)
1291+
_SEVERITY_COLORS = {"[WARN]": "yellow", "[ERR]": "red"}
1292+
for line in lines:
1293+
click.secho(line, fg=_SEVERITY_COLORS.get(line.split(" ", 1)[0]))
1294+
click.secho(summary, fg="green" if result.ok else "red")
1295+
if not result.ok:
12981296
ctx.exit(1)
12991297

13001298

1299+
@config_group.command("init-repo")
1300+
@click.option(
1301+
"--workspace", "workspace", type=click.Path(), default=None,
1302+
help="Workspace to scaffold (default: the resolved workspace).",
1303+
)
1304+
@click.option(
1305+
"--dry-run", is_flag=True, help="Show what would be created without writing.",
1306+
)
1307+
@click.pass_context
1308+
def config_init_repo(
1309+
ctx: click.Context, workspace: str | None, dry_run: bool,
1310+
) -> None:
1311+
"""Scaffold the workspace into a shareable git config repo.
1312+
1313+
Drops a CI validation workflow, a secrets-aware .gitignore, and a README into
1314+
the workspace (never overwriting existing files), then prints the remaining
1315+
git/gh and instance-config steps. Safe to re-run.
1316+
"""
1317+
from nerve.config_repo import scaffold_config_repo
1318+
1319+
if workspace:
1320+
ws = Path(workspace).expanduser()
1321+
else:
1322+
config = ctx.obj["config"]
1323+
if config is None:
1324+
raise click.ClickException(
1325+
"Config could not be loaded; pass --workspace or run 'nerve doctor'."
1326+
)
1327+
ws = Path(config.workspace)
1328+
1329+
if not ws.is_dir():
1330+
raise click.ClickException(f"Workspace does not exist: {ws}")
1331+
1332+
result = scaffold_config_repo(ws, dry_run=dry_run)
1333+
1334+
verb = "Would create" if dry_run else "Created"
1335+
for rel in result.created:
1336+
click.secho(f" {verb}: {rel}", fg="green")
1337+
for rel in result.skipped:
1338+
click.secho(f" Skipped (exists): {rel}", fg="yellow")
1339+
if not result.created:
1340+
click.echo("Config-repo scaffold already in place — nothing to create.")
1341+
1342+
# Remaining steps the CLI can't do for you (they need a repo URL + auth).
1343+
click.secho("\nNext steps:", bold=True)
1344+
click.echo(f" cd {ws}")
1345+
if not result.is_git_repo:
1346+
click.echo(" git init && git add -A && git commit -m 'Initial Nerve config'")
1347+
else:
1348+
click.echo(" git add -A && git commit -m 'Add config-repo scaffold'")
1349+
click.echo(
1350+
" gh repo create <org>/nerve-config --private --source=. "
1351+
"--remote=origin --push"
1352+
)
1353+
click.echo(
1354+
"\nCI validates every PR out of the box (no secrets needed) — see\n"
1355+
".github/workflows/validate-config.yml."
1356+
)
1357+
# Only describe the pin this run actually wrote. Saying "pinned" over a
1358+
# workflow tracking a moving branch — or over one left untouched because it
1359+
# already existed — is how an operator stops checking it.
1360+
if not result.validator_ref:
1361+
click.echo(
1362+
"That workflow already existed and was left as-is; check its `ref:`\n"
1363+
"still matches the nerve this instance runs."
1364+
)
1365+
elif result.validator_pinned:
1366+
click.echo(
1367+
f"Its nerve checkout is pinned to {result.validator_ref[:12]}, the\n"
1368+
f"revision this instance runs. Bump it when you upgrade."
1369+
)
1370+
else:
1371+
click.secho(
1372+
f"Its nerve checkout could NOT be pinned and tracks "
1373+
f"'{result.validator_ref}', which moves.\n"
1374+
f"Read the note above that step and replace it with the nerve version\n"
1375+
f"you deploy.",
1376+
fg="yellow",
1377+
)
1378+
click.echo(
1379+
"\nOn the instance, enable sync in workspace/config/settings.yaml:\n"
1380+
" workspace_sync:\n"
1381+
" enabled: true\n"
1382+
" branch: main\n"
1383+
" interval_minutes: 5\n"
1384+
"Then verify — the same check CI runs, so a pass here means a green PR:\n"
1385+
f" nerve config validate --workspace {ws} --portable-only --strict-keys\n"
1386+
"Drop --portable-only to include this machine's config.yaml and\n"
1387+
"config.local.yaml, i.e. what the daemon itself loads.\n"
1388+
"When ready to lock down, set 'lockdown: true' in settings.yaml (move\n"
1389+
"secrets to ${ENV_VAR} first — see docs/config.md)."
1390+
)
1391+
1392+
13011393
@main.group()
13021394
def codex() -> None:
13031395
"""Inspect and authenticate the Codex integration."""

‎nerve/config_pr.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,14 @@ def propose_config_change(
616616
)
617617

618618
# Validate the proposed bundle — never open a PR for a broken config.
619+
#
620+
# Deliberately the lenient question, not the one the config repo's own CI
621+
# asks: that workflow adds --strict-keys and --portable-only, so a typo'd
622+
# key it would reject only gets a warning here and the PR still opens. The
623+
# asymmetry is on purpose — the reviewer sees the red check and the
624+
# explanation with the change in front of them, which is a better place to
625+
# settle "is this key real" than a refusal here with no diff to look at.
626+
# Erring the other way would block proposals on keys a newer nerve knows.
619627
from nerve.config_validate import validate_config_bundle
620628
errors = validate_config_bundle(config_dir, workspace_override=wt).errors
621629
if errors:

0 commit comments

Comments
 (0)