feat: Publish-Remote Gate und die Anleitung fuer eine private Instanz (2.2.0)
Files changed: - .gitignore - AGENTS.md - CHANGES.md - VERSION - instructions/gates.md - instructions/private-instance.md - tools/chemenu/commands/doctor.py - tools/chemenu/commands/git_publish.py - tools/chemenu/config.py - tools/chemenu/tests/test_git_publish.py
This commit is contained in:
@@ -110,6 +110,13 @@ npm-debug.log*
|
|||||||
# `dist export`; this anchored pattern deliberately does not match it.
|
# `dist export`; this anchored pattern deliberately does not match it.
|
||||||
/ENVIRONMENT.md
|
/ENVIRONMENT.md
|
||||||
|
|
||||||
|
# Publish-Remote Gate allowlist (see instructions/gates.md). Names the push
|
||||||
|
# URLs *this* checkout may publish to, so it is per-checkout for exactly the
|
||||||
|
# reason ENVIRONMENT.md above is: a committed copy would tell a private clone
|
||||||
|
# that the public upstream is a legitimate target for its own content. Absent
|
||||||
|
# means unrestricted; `doctor` reports which.
|
||||||
|
/.wikitool-remotes.json
|
||||||
|
|
||||||
# Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived,
|
# Coverage output from `pytest --cov` (see .gitea/workflows/ci.yml). Derived,
|
||||||
# like reports/: recomputable from any commit, and `publish` runs `git add -A`,
|
# like reports/: recomputable from any commit, and `publish` runs `git add -A`,
|
||||||
# so an unignored htmlcov/ would commit itself on the next content publish.
|
# so an unignored htmlcov/ would commit itself on the next content publish.
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ tools/wikitool instructions sync
|
|||||||
Full procedure, including the tool environment: [instructions/bootstrap.md](instructions/bootstrap.md).
|
Full procedure, including the tool environment: [instructions/bootstrap.md](instructions/bootstrap.md).
|
||||||
Setting up a brand-new, empty instance instead of cloning this one: `tools/wikitool dist export`
|
Setting up a brand-new, empty instance instead of cloning this one: `tools/wikitool dist export`
|
||||||
and [instructions/setup-instance.md](instructions/setup-instance.md) - see
|
and [instructions/setup-instance.md](instructions/setup-instance.md) - see
|
||||||
[INSTALL.md](INSTALL.md).
|
[INSTALL.md](INSTALL.md). A *private* instance that keeps taking stack updates from a public
|
||||||
|
upstream is a third shape, with a safeguard the other two do not need:
|
||||||
|
[instructions/private-instance.md](instructions/private-instance.md).
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
@@ -177,16 +179,19 @@ tools/wikitool search --field entity_type=system --field 'confidence<0.6'
|
|||||||
|
|
||||||
## Gates
|
## Gates
|
||||||
|
|
||||||
Two limits are enforced in code rather than by instruction, because a prompt-level limit is
|
Three limits are enforced in code rather than by instruction, because a prompt-level limit is
|
||||||
one an agent can talk itself past.
|
one an agent can talk itself past.
|
||||||
|
|
||||||
- **Mass-Update Gate.** `publish` exits **42** on a change touching too many files, printing
|
- **Mass-Update Gate.** `publish` exits **42** on a change touching too many files, printing
|
||||||
the file list and the `--confirm <token>` line that publishes it once the user approves. The
|
the file list and the `--confirm <token>` line that publishes it once the user approves. The
|
||||||
threshold and the rule live in [instructions/gates.md](instructions/gates.md).
|
threshold and the rule live in [instructions/gates.md](instructions/gates.md).
|
||||||
|
- **Publish-Remote Gate.** `publish` exits **42** on a push to a URL this checkout has not
|
||||||
|
declared in `.wikitool-remotes.json`. It has no token and no flag: the way past it is a
|
||||||
|
deliberate edit by the user, never by an agent.
|
||||||
- **Iteration Budget Gate / Loop-Breaker.** Past 60 `wikitool` calls in a session, or after 3
|
- **Iteration Budget Gate / Loop-Breaker.** Past 60 `wikitool` calls in a session, or after 3
|
||||||
identical calls in a row, further calls are refused.
|
identical calls in a row, further calls are refused.
|
||||||
|
|
||||||
Both refuse with exit 1. **Do not retry, and do not open the gate.** Stop, summarize the
|
The last refuses with exit 1. **Do not retry, and do not open a gate.** Stop, summarize the
|
||||||
situation to the user, and get explicit approval. The full procedure - including why
|
situation to the user, and get explicit approval. The full procedure - including why
|
||||||
`budget reset` is not the escape hatch - is [instructions/gates.md](instructions/gates.md).
|
`budget reset` is not the escape hatch - is [instructions/gates.md](instructions/gates.md).
|
||||||
|
|
||||||
|
|||||||
+57
-1
@@ -20,6 +20,62 @@ their date-only headings.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2.2.0 - 2026-09-01 - Publish-Remote Gate: publish schreibt nur an erklaerte Ziele
|
||||||
|
|
||||||
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
|
Der Stack bekommt sein drittes Gate. Die beiden bestehenden fragen, ob eine Änderung zu groß
|
||||||
|
ist und ob ein Rebase gefährlich ist. Dieses fragt, was darunter liegt: **ob das überhaupt das
|
||||||
|
richtige Repository ist.**
|
||||||
|
|
||||||
|
**Das Problem entsteht erst durch die private Instanz.** Ein Checkout mit eigenem Inhalt hat
|
||||||
|
typischerweise zwei Remotes — sein eigenes und das öffentliche Upstream, von dem er
|
||||||
|
Stack-Updates zieht. Git unterscheidet die beim Push nicht, also legt ein falsches `--remote`
|
||||||
|
einen privaten Korpus auf ein öffentliches Repository. Das ist nicht billig rückholbar, und
|
||||||
|
zwar nachweislich: Beim Veröffentlichen dieses Repos blieb die gesamte alte History nach dem
|
||||||
|
Force-Push per SHA abrufbar, bis auf dem Server die Reflogs verfielen und `git gc --prune=now`
|
||||||
|
lief. Ein Force-Push bewegt den Branch, nicht die Objekte.
|
||||||
|
|
||||||
|
**`.wikitool-remotes.json` nennt die erlaubten Push-URLs.** Nicht die Remote-*Namen*: Eine
|
||||||
|
Namensliste ließe ein `publish` durch, dessen `origin` umgebogen wurde, und genau das ist der
|
||||||
|
Fall, den das Gate fangen soll. Gelesen wird die `pushurl`, wenn der Remote eine setzt, denn
|
||||||
|
dorthin schreibt `git push` tatsächlich.
|
||||||
|
|
||||||
|
**Pro Checkout und gitignored**, aus demselben Grund wie `ENVIRONMENT.md`: Zwei Klone pushen an
|
||||||
|
zwei verschiedene Orte, eine committete Kopie würde einem privaten Klon also mitteilen, das
|
||||||
|
öffentliche Upstream sei ein legitimes Ziel für seinen eigenen Inhalt. **Fehlt die Datei, gilt
|
||||||
|
keine Beschränkung** — ein Checkout mit einem Remote und ohne Privates hat nichts zu schützen,
|
||||||
|
und eine Pflichtdatei würde aus einer Sicherung Papierkram machen. Eine *kaputte* Datei ist
|
||||||
|
dagegen ein Fehler und kein „keine Beschränkung": Eine beschädigte Sicherung darf sich nicht
|
||||||
|
wie eine abgeschaltete verhalten.
|
||||||
|
|
||||||
|
**Kein Token, keine Flagge.** Die anderen beiden Gates lösen sich mit einem `--confirm <token>`,
|
||||||
|
weil ihre Frage („ist diese Änderung richtig?") für genau ein Changeset beantwortbar ist. Dieses
|
||||||
|
fragt „gehört dieser Inhalt in jenes Repository?", und das ist eine stehende Eigenschaft des
|
||||||
|
Checkouts, kein Einzelfallurteil. Der Weg daran vorbei ist ein bewusster Edit des Nutzers.
|
||||||
|
Ein Agent, der die Datei anfasst, um an einer Verweigerung vorbeizukommen, öffnet ein Gate aus
|
||||||
|
eigenem Antrieb — Invariante 6.
|
||||||
|
|
||||||
|
**`doctor` meldet den Zustand** statt ihn zu erzwingen: OK mit Anzahl der Ziele, OK bei
|
||||||
|
Abwesenheit mit einem Remote, und WARN bei mehr als einem Remote ohne Allowlist — also genau in
|
||||||
|
der Form, die eine private Instanz annimmt, sobald sie das Upstream hinzufügt.
|
||||||
|
|
||||||
|
**Und die Prozedur, für die das Gate gebaut wurde.** `instructions/private-instance.md` (neu)
|
||||||
|
beschreibt die dritte Instanz-Form neben „frisch aufsetzen" und „Repo klonen": eine private
|
||||||
|
Arbeitsinstanz, die Stack-Updates von einem öffentlichen Upstream per `git merge` zieht und
|
||||||
|
deren eigener Inhalt nie zurückwandert. Der Grund, warum das dem Tarball-Weg vorzuziehen ist,
|
||||||
|
steht dort ausformuliert — `cp -r` hat keinen Drei-Wege-Merge und keine Konflikterkennung.
|
||||||
|
Schritt 4 der Anleitung ist das Gate, und zwar ausdrücklich **vor** dem ersten `publish`:
|
||||||
|
später hinzugefügt schützt es das Fenster nicht, das es schließen soll.
|
||||||
|
|
||||||
|
**Dateien:** `config.PUBLISH_REMOTES_FILENAME`, `git_publish.read_allowed_push_urls()`,
|
||||||
|
`push_url_for()`, `publish_remote_refusal()` und die Prüfung vor dem Reconcile-Schritt,
|
||||||
|
`doctor.check_publish_remotes()`, `.gitignore`, `instructions/gates.md`,
|
||||||
|
`instructions/private-instance.md` (neu), `AGENTS.md` (Gate-Liste und Bootstrap-Routing),
|
||||||
|
12 neue Tests in `test_git_publish.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2.1.1 - 2026-09-01 - raw_dir-Fixture kappt config.ROOT; letzte private Fixture-Namen ersetzt
|
## 2.1.1 - 2026-09-01 - raw_dir-Fixture kappt config.ROOT; letzte private Fixture-Namen ersetzt
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
@@ -47,7 +103,7 @@ Nachgewiesen, indem `raw/documents/` lokal entfernt und die Suite erneut gefahre
|
|||||||
Provenance-Tests grün ohne das Verzeichnis, vorher rot.
|
Provenance-Tests grün ohne das Verzeichnis, vorher rot.
|
||||||
|
|
||||||
**Fixture-Namen.** Die Suite benutzte weiterhin reale Systemnamen der Ursprungsinstanz als
|
**Fixture-Namen.** Die Suite benutzte weiterhin reale Systemnamen der Ursprungsinstanz als
|
||||||
Fixture-Bezeichner (`atlantis`, `abydos`, `Nathan`, `hermes`, `ecodms`, `Opa Hasso`). In einem
|
Fixture-Bezeichner. In einem
|
||||||
öffentlichen Repo beschreiben sie nichts, verraten aber die Namensgebung einer privaten
|
öffentlichen Repo beschreiben sie nichts, verraten aber die Namensgebung einer privaten
|
||||||
Umgebung. Ersetzt durch `aurora`, `almanac`, `Borealis`, `gateway`, `docstore`. `gdeploy`
|
Umgebung. Ersetzt durch `aurora`, `almanac`, `Borealis`, `gateway`, `docstore`. `gdeploy`
|
||||||
bleibt: die Seite existiert im öffentlichen Korpus.
|
bleibt: die Seite existiert im öffentlichen Korpus.
|
||||||
|
|||||||
+41
-4
@@ -22,10 +22,11 @@ Read the exit code first - it says which of these applies:
|
|||||||
## Exit 42: user clearance required
|
## Exit 42: user clearance required
|
||||||
|
|
||||||
A `wikitool` command that exits **42** is not reporting an error. It is refusing to act until a
|
A `wikitool` command that exits **42** is not reporting an error. It is refusing to act until a
|
||||||
human has *read its output*. Two gates use it today - the Mass-Update Gate (`publish`, on a
|
human has *read its output*. Three gates use it today - the Mass-Update Gate (`publish`, on a
|
||||||
change touching 10 or more counted files) and the rebase-review gate (`sync` and `publish`, on
|
change touching 10 or more counted files), the rebase-review gate (`sync` and `publish`, on
|
||||||
a rebase whose incoming commits touch a file this session is also changing) - but the rule is
|
a rebase whose incoming commits touch a file this session is also changing), and the
|
||||||
about the exit code, not the command:
|
Publish-Remote Gate (`publish`, on a push to a target this checkout has not declared) - but the
|
||||||
|
rule is about the exit code, not the command:
|
||||||
|
|
||||||
> **Copy the command's output into your reply - the substance of it, not a description of it -
|
> **Copy the command's output into your reply - the substance of it, not a description of it -
|
||||||
> and stop.** Run no further commands in that turn.
|
> and stop.** Run no further commands in that turn.
|
||||||
@@ -62,6 +63,42 @@ clearance.
|
|||||||
|
|
||||||
Background: [[Mass-Update Gate]] (`kb/concepts/Mass-Update Gate.md`).
|
Background: [[Mass-Update Gate]] (`kb/concepts/Mass-Update Gate.md`).
|
||||||
|
|
||||||
|
### Publish-Remote Gate
|
||||||
|
|
||||||
|
The Mass-Update Gate asks whether a change is too large to publish. This one asks the question
|
||||||
|
underneath it: **whether this is the right repository to publish to at all.**
|
||||||
|
|
||||||
|
A checkout that holds private content usually has two remotes - its own, and the public upstream
|
||||||
|
it takes stack updates from. Git does not distinguish them at push time, so one wrong `--remote`
|
||||||
|
puts a private corpus on a public repository. That is not cheaply reversible: a force-push moves
|
||||||
|
the branch, but the objects stay fetchable by SHA until someone expires the server's reflogs and
|
||||||
|
runs `git gc --prune=now` on the bare repo.
|
||||||
|
|
||||||
|
`.wikitool-remotes.json` names the push URLs a checkout permits:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "schema": 1, "allowed_push_urls": ["ssh://git@example.net:22/you/your-wiki.git"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
It pins **URLs, not remote names** - a name-based list would wave through a `publish` whose
|
||||||
|
`origin` had been repointed, which is the failure it exists to catch. It reads the remote's
|
||||||
|
`pushurl` when one is set, because that is where `git push` actually writes.
|
||||||
|
|
||||||
|
The file is per-checkout and gitignored, for the same reason `ENVIRONMENT.md` is: two clones push
|
||||||
|
to two different places, so a committed copy would tell a private clone that the public upstream
|
||||||
|
is a legitimate target for its own content. **Absent means unrestricted** - a single-remote
|
||||||
|
checkout with nothing private in it has nothing to protect, and `doctor` reports which state a
|
||||||
|
checkout is in, WARNing only when there is more than one remote and no allowlist. A malformed
|
||||||
|
file is an error rather than "no restriction": a corrupted safeguard must not read as a disabled
|
||||||
|
one.
|
||||||
|
|
||||||
|
**This gate has no `--confirm` token, on purpose.** The other two clear with a token because the
|
||||||
|
question they ask ("is this change right?") is one the agent can put to the user and the user can
|
||||||
|
answer for that one changeset. This one asks "does this content belong to that repository?", which
|
||||||
|
is a standing property of the checkout, not a per-push judgment. The way past it is for the user
|
||||||
|
to add the URL to the file. **An agent must never edit `.wikitool-remotes.json` to get past a
|
||||||
|
refusal** - that is opening a gate on your own initiative, which AGENTS.md invariant 6 forbids.
|
||||||
|
|
||||||
## Iteration Budget Gate and loop-breaker
|
## Iteration Budget Gate and loop-breaker
|
||||||
|
|
||||||
Every `wikitool` call is counted per session. Calls are refused past **60 in a session**, or
|
Every `wikitool` call is counted per session. Calls are refused past **60 in a session**, or
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
type: types/instruction.md
|
||||||
|
name: private-instance
|
||||||
|
description: Set up a private working instance as a clone of a public upstream, so stack updates arrive by merge instead of by copying a tarball over the tree.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Set up a private instance against a public upstream
|
||||||
|
|
||||||
|
The distribution path in [setup-instance.md](setup-instance.md) builds an instance from a
|
||||||
|
`dist export` tarball, with no git ancestry in common with the repo it came from. That is the
|
||||||
|
right shape for someone who only ever *consumes* the stack.
|
||||||
|
|
||||||
|
This is the other shape: a private instance that keeps taking stack changes from a public
|
||||||
|
upstream, and whose own content must never travel back. It costs one safeguard to set up and
|
||||||
|
saves the whole update procedure afterwards.
|
||||||
|
|
||||||
|
**Read this before, not after, the first `publish`.** The gate in step 4 is the thing that makes
|
||||||
|
the arrangement safe, and adding it later means the window it closes was open in between.
|
||||||
|
|
||||||
|
## Why a clone rather than a tarball
|
||||||
|
|
||||||
|
`INSTALL.md`'s "Eine Instanz aktualisieren" is `cp -r` as an upgrade strategy: copy `tools/`,
|
||||||
|
`types/`, `instructions/`, `AGENTS.md`, `VERSION` over the existing tree. It has no three-way
|
||||||
|
merge, so it cannot notice that the receiving instance changed a file, and it has no conflict
|
||||||
|
surface, so nobody learns when upstream and local both touched the same one. It overwrites
|
||||||
|
silently.
|
||||||
|
|
||||||
|
A clone gets all of that from git. The private `main` deletes the upstream's demo corpus once;
|
||||||
|
every later `git merge upstream/main` sees *deleted-in-ours, unmodified-in-theirs* and resolves
|
||||||
|
without asking. Stack changes land as real merges, with real conflicts where they conflict.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Clone, and name the two remotes for what they are.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <private-repo-url> my-wiki
|
||||||
|
cd my-wiki
|
||||||
|
git remote add upstream <public-repo-url>
|
||||||
|
```
|
||||||
|
|
||||||
|
`origin` is yours and is the only thing you ever push to. `upstream` is where stack updates
|
||||||
|
come from and is fetch-only.
|
||||||
|
|
||||||
|
2. **Make the fetch-only half fetch-only in git, too.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote set-url --push upstream no_push
|
||||||
|
```
|
||||||
|
|
||||||
|
git refuses to push to a URL it cannot resolve. This is a convenience, not the safeguard -
|
||||||
|
step 4 is the safeguard.
|
||||||
|
|
||||||
|
3. **Delete the upstream's demo corpus once, on your own `main`.**
|
||||||
|
|
||||||
|
Everything under `kb/` and `raw/` that came with the clone is the upstream's content, not
|
||||||
|
yours. Remove it with `wikitool rm --page` (never `rm -rf`: `rm` de-links each page from the
|
||||||
|
rest of the wiki, and a plain delete leaves dead wikilinks and broken citations behind), then
|
||||||
|
`index rebuild`, `sources rebuild-index`, `lint`.
|
||||||
|
|
||||||
|
This is a one-time cut. Afterwards the upstream corpus is frozen from your side, which is
|
||||||
|
what makes later merges content-free.
|
||||||
|
|
||||||
|
4. **Arm the Publish-Remote Gate — before the first `publish`.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > .wikitool-remotes.json <<'EOF'
|
||||||
|
{ "schema": 1, "allowed_push_urls": ["<your-private-push-url>"] }
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the URL `git remote get-url --push origin` prints, exactly. `publish` refuses with exit
|
||||||
|
42 for anything else, and there is no flag that opens it - see [gates.md](gates.md).
|
||||||
|
|
||||||
|
The file is gitignored, so it stays with this checkout and never travels to the upstream.
|
||||||
|
`wikitool doctor` reports whether the gate is armed, and WARNs at more than one remote
|
||||||
|
without it.
|
||||||
|
|
||||||
|
5. **Take away the write credential, if you can.** A token or deploy key for `origin` only,
|
||||||
|
with no write access to the upstream, is the one control that holds even if everything above
|
||||||
|
is misconfigured. Belt and braces.
|
||||||
|
|
||||||
|
6. **Personalize and bootstrap.** `USER.md`, `SOUL.md` and optionally `ENVIRONMENT.md` are
|
||||||
|
yours and unrelated to the upstream's - see the Personalization step of
|
||||||
|
[setup-instance.md](setup-instance.md), then [bootstrap.md](bootstrap.md) for the venv and
|
||||||
|
the skills.
|
||||||
|
|
||||||
|
## Taking a stack update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch upstream
|
||||||
|
git merge upstream/main
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, as after any stack change: `doctor`, `docs verify`, `instructions verify`, `migrate status`,
|
||||||
|
`lint`. A `migrate status` with outstanding links means the update crossed a compatibility
|
||||||
|
boundary - follow [migrate-corpus.md](migrate-corpus.md) before doing anything else.
|
||||||
|
|
||||||
|
## Where stack development happens
|
||||||
|
|
||||||
|
**In the public repo, not here.** That is not a preference; the stack is built that way. The
|
||||||
|
development-only half of the instruction layer is pruned from a distribution one-way, with no
|
||||||
|
command that reconstructs it, so an instance built this way has no tool-development mode to
|
||||||
|
switch into in the first place.
|
||||||
|
|
||||||
|
When a tool bug blocks real content work here - and it will - file the issue against the public
|
||||||
|
repo (an MCP server or the web UI reaches it from any session; no shared history needed), fix it
|
||||||
|
there where the tests, `docs verify` and CI's version gate live, and take the fix back with the
|
||||||
|
merge above. Nothing is lost by the detour: the fix has to pass that CI either way.
|
||||||
|
|
||||||
|
## Decision points
|
||||||
|
|
||||||
|
- **Merge conflict in `kb/` or `raw/`?** Something changed the upstream's corpus after you cut
|
||||||
|
it. Resolve as "keep deleted" - your instance's content is yours, and the upstream's demo
|
||||||
|
corpus has no business in it.
|
||||||
|
- **Conflict in `tools/`, `types/` or `instructions/`?** You changed the stack locally, which
|
||||||
|
step "Where stack development happens" says not to do. Take the upstream side and re-file the
|
||||||
|
change as an issue there.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Not for a first instance with no upstream - that is [setup-instance.md](setup-instance.md). Not
|
||||||
|
for a fresh clone of a repo you already own and develop in - that is
|
||||||
|
[bootstrap.md](bootstrap.md). This is specifically the two-remote case, where the cost of a
|
||||||
|
mistaken push is disclosure rather than inconvenience.
|
||||||
@@ -21,7 +21,7 @@ import typer
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from chemenu import config, kb_collections, version as version_mod
|
from chemenu import config, kb_collections, version as version_mod
|
||||||
from chemenu.commands import instructions_cmd
|
from chemenu.commands import git_publish, instructions_cmd
|
||||||
from chemenu.commands._util import rel_path
|
from chemenu.commands._util import rel_path
|
||||||
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
from chemenu.session import ENV_VAR as SESSION_ENV_VAR
|
||||||
|
|
||||||
@@ -242,6 +242,42 @@ def check_environment() -> Check:
|
|||||||
return Check("environment", "OK", f"{config.ENVIRONMENT_FILE} present and filled")
|
return Check("environment", "OK", f"{config.ENVIRONMENT_FILE} present and filled")
|
||||||
|
|
||||||
|
|
||||||
|
def check_publish_remotes() -> Check:
|
||||||
|
"""Whether the Publish-Remote Gate is armed in this checkout.
|
||||||
|
|
||||||
|
Absent is a legitimate state, not a fault: a checkout with a single remote
|
||||||
|
and nothing private in it has nothing to protect, and making the file
|
||||||
|
mandatory would turn a safeguard into paperwork. So this never FAILs - it
|
||||||
|
reports, the way `environment` does.
|
||||||
|
|
||||||
|
It does WARN for the case that actually bites: more than one remote
|
||||||
|
configured and no allowlist. That is the shape a private instance has after
|
||||||
|
it adds the public upstream, and it is exactly when a wrong `--remote`
|
||||||
|
stops being a typo and starts being a disclosure.
|
||||||
|
"""
|
||||||
|
urls = git_publish.read_allowed_push_urls()
|
||||||
|
if urls is not None:
|
||||||
|
return Check(
|
||||||
|
"publish-remotes", "OK",
|
||||||
|
f"{len(urls)} allowed push target(s) in {config.PUBLISH_REMOTES_FILENAME}",
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "remote"], cwd=config.ROOT, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
remotes = [r for r in result.stdout.split() if r]
|
||||||
|
if len(remotes) > 1:
|
||||||
|
return Check(
|
||||||
|
"publish-remotes", "WARN",
|
||||||
|
f"{len(remotes)} remotes ({', '.join(remotes)}) and no publish allowlist",
|
||||||
|
f"Create {config.PUBLISH_REMOTES_FILENAME} naming the push URL this checkout "
|
||||||
|
"may publish to - see instructions/gates.md",
|
||||||
|
)
|
||||||
|
return Check(
|
||||||
|
"publish-remotes", "OK",
|
||||||
|
f"No {config.PUBLISH_REMOTES_FILENAME} (unrestricted; one remote configured)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_generated_files() -> Check:
|
def check_generated_files() -> Check:
|
||||||
missing = [
|
missing = [
|
||||||
rel_path(path)
|
rel_path(path)
|
||||||
@@ -355,6 +391,7 @@ def run_doctor() -> list[Check]:
|
|||||||
check_structure(),
|
check_structure(),
|
||||||
check_personalization(),
|
check_personalization(),
|
||||||
check_environment(),
|
check_environment(),
|
||||||
|
check_publish_remotes(),
|
||||||
check_generated_files(),
|
check_generated_files(),
|
||||||
check_session_id(),
|
check_session_id(),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -80,6 +80,86 @@ def _run(args: list[str]) -> subprocess.CompletedProcess:
|
|||||||
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
|
return subprocess.run(args, cwd=config.ROOT, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Publish-Remote Gate -----------------------------------------------------
|
||||||
|
#
|
||||||
|
# The Mass-Update Gate asks "is this too much to publish?". This one asks the
|
||||||
|
# question underneath it: "is this the right place to publish to at all?".
|
||||||
|
#
|
||||||
|
# A checkout holding private content typically has two remotes - its own, and
|
||||||
|
# the public upstream it takes stack updates from. Nothing in git distinguishes
|
||||||
|
# them at push time, so a single wrong `--remote` puts a private corpus on a
|
||||||
|
# public repository, where a force-push does not take it back: the objects stay
|
||||||
|
# fetchable by SHA until someone expires the server's reflogs.
|
||||||
|
#
|
||||||
|
# Like the other two gates this refuses with exit 42 and has **no flag that
|
||||||
|
# opens it**. The way past it is to name the URL in the file, which is an edit
|
||||||
|
# the user makes deliberately rather than something an agent can decide mid-run.
|
||||||
|
|
||||||
|
|
||||||
|
def read_allowed_push_urls() -> Optional[list[str]]:
|
||||||
|
"""The URLs this checkout permits `publish` to push to, or None when the
|
||||||
|
file is absent (unrestricted - see config.PUBLISH_REMOTES_FILENAME)."""
|
||||||
|
path = config.ROOT / config.PUBLISH_REMOTES_FILENAME
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
fail(
|
||||||
|
f"{config.PUBLISH_REMOTES_FILENAME} is unreadable ({exc}). It decides where "
|
||||||
|
"`publish` may push, so a broken one is not treated as 'no restriction' - "
|
||||||
|
"fix the file or delete it deliberately."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
urls = data.get("allowed_push_urls")
|
||||||
|
if not isinstance(urls, list) or not all(isinstance(u, str) for u in urls):
|
||||||
|
fail(
|
||||||
|
f"{config.PUBLISH_REMOTES_FILENAME} has no usable `allowed_push_urls` list of "
|
||||||
|
"strings. Expected: {\"schema\": 1, \"allowed_push_urls\": [\"<url>\"]}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def push_url_for(remote: str) -> Optional[str]:
|
||||||
|
"""The URL `git push <remote>` would actually write to - `pushurl` when the
|
||||||
|
remote sets one, otherwise its fetch URL. Reading the resolved value rather
|
||||||
|
than the name is the whole point: a repointed `origin` must not pass."""
|
||||||
|
result = _run(["git", "remote", "get-url", "--push", remote])
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return result.stdout.strip() or None
|
||||||
|
|
||||||
|
|
||||||
|
def publish_remote_refusal(remote: str, branch: str) -> Optional[str]:
|
||||||
|
"""The gate's message when `remote` is not an allowed push target, else None."""
|
||||||
|
allowed = read_allowed_push_urls()
|
||||||
|
if allowed is None:
|
||||||
|
return None
|
||||||
|
url = push_url_for(remote)
|
||||||
|
if url is None:
|
||||||
|
return (
|
||||||
|
f"Publish-Remote Gate: '{remote}' resolves to no push URL, so this publish "
|
||||||
|
f"cannot be checked against {config.PUBLISH_REMOTES_FILENAME}. Nothing was "
|
||||||
|
"committed or pushed."
|
||||||
|
)
|
||||||
|
if url in allowed:
|
||||||
|
return None
|
||||||
|
listed = "\n".join(f" - {u}" for u in allowed) or " (the list is empty)"
|
||||||
|
return (
|
||||||
|
f"Publish-Remote Gate: this checkout does not allow publishing to '{remote}'.\n\n"
|
||||||
|
f" would push to: {url}\n"
|
||||||
|
f" allowed here:\n{listed}\n\n"
|
||||||
|
"Nothing was committed or pushed. This checkout holds content that belongs to it "
|
||||||
|
"alone, and a push to the wrong remote is not cheaply reversible - the objects stay "
|
||||||
|
"fetchable by SHA even after a force-push, until the server's reflogs are expired.\n\n"
|
||||||
|
"THE USER CANNOT SEE THIS OUTPUT. It went to your context, not to their screen.\n"
|
||||||
|
"Show them the two lines above and stop. There is no flag that opens this gate: if "
|
||||||
|
f"the target really is right, the user adds its URL to {config.PUBLISH_REMOTES_FILENAME} "
|
||||||
|
"themselves. Do not edit that file to get past this."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_porcelain_entries(stdout: str) -> list[tuple[str, str]]:
|
def parse_porcelain_entries(stdout: str) -> list[tuple[str, str]]:
|
||||||
"""Parse `git status --porcelain -z` output into (status_code, path) pairs.
|
"""Parse `git status --porcelain -z` output into (status_code, path) pairs.
|
||||||
|
|
||||||
@@ -919,6 +999,26 @@ def publish_command(
|
|||||||
if push and checked_out != branch:
|
if push and checked_out != branch:
|
||||||
fail(branch_mismatch_message(checked_out, branch))
|
fail(branch_mismatch_message(checked_out, branch))
|
||||||
|
|
||||||
|
# Before the reconcile below, which is the first thing that talks to the
|
||||||
|
# remote at all: a publish aimed at the wrong repository should not even
|
||||||
|
# fetch from it, and the refusal message promises that nothing was
|
||||||
|
# committed or pushed.
|
||||||
|
if push:
|
||||||
|
refusal = publish_remote_refusal(remote, branch)
|
||||||
|
if refusal:
|
||||||
|
emit(
|
||||||
|
"wikitool",
|
||||||
|
"gate.refused",
|
||||||
|
{
|
||||||
|
"gate": "publish-remote",
|
||||||
|
"reason": "remote-not-allowed",
|
||||||
|
"remote": remote,
|
||||||
|
"branch": branch,
|
||||||
|
"url": push_url_for(remote),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
needs_clearance(refusal)
|
||||||
|
|
||||||
# Pull against the remote before anything else, to minimise the window in which this
|
# Pull against the remote before anything else, to minimise the window in which this
|
||||||
# publish could diverge from it - and, as a side effect, to finally publish a commit left
|
# publish could diverge from it - and, as a side effect, to finally publish a commit left
|
||||||
# stranded by a previous push that failed (see `_local_ahead_of_remote` below). Skipped
|
# stranded by a previous push that failed (see `_local_ahead_of_remote` below). Skipped
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ ENVIRONMENT_TEMPLATE = f"{ENVIRONMENT_FILE}.template"
|
|||||||
# `dist export` already computes (AGENTS.md invariant 8). See NOTICE.
|
# `dist export` already computes (AGENTS.md invariant 8). See NOTICE.
|
||||||
LICENSE_FILES = ("LICENSE", "LICENSE-CONTENT", "NOTICE")
|
LICENSE_FILES = ("LICENSE", "LICENSE-CONTENT", "NOTICE")
|
||||||
|
|
||||||
|
# Which push targets `publish` may write to, for a checkout that says so. The
|
||||||
|
# danger this addresses is one checkout's content reaching another checkout's
|
||||||
|
# remote - a private instance pushing its own `kb/` to a public upstream, where
|
||||||
|
# it cannot be taken back.
|
||||||
|
#
|
||||||
|
# It pins **URLs, not remote names**: a name-based list would pass a `publish`
|
||||||
|
# whose `origin` had been repointed, which is the failure it exists to catch.
|
||||||
|
#
|
||||||
|
# Per-checkout and gitignored, like `ENVIRONMENT.md` and for the same reason:
|
||||||
|
# two clones of this repo push to two different places, so a committed copy
|
||||||
|
# would hand the second one an answer that is wrong rather than missing. Absent
|
||||||
|
# means unrestricted - `doctor` reports it, and the Publish-Remote Gate simply
|
||||||
|
# does not apply. A checkout that holds private content should have one; see
|
||||||
|
# instructions/gates.md.
|
||||||
|
PUBLISH_REMOTES_FILENAME = ".wikitool-remotes.json"
|
||||||
|
|
||||||
|
|
||||||
def default_author() -> str | None:
|
def default_author() -> str | None:
|
||||||
"""The author to stamp a new source page with, per instance.
|
"""The author to stamp a new source page with, per instance.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -905,3 +906,115 @@ def test_numstat_survives_a_non_ascii_filename(repo):
|
|||||||
change = next(c for c in collect_changes([]) if c.path == name)
|
change = next(c for c in collect_changes([]) if c.path == name)
|
||||||
assert change.status == "modified"
|
assert change.status == "modified"
|
||||||
assert (change.added, change.removed) == (1, 2)
|
assert (change.added, change.removed) == (1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Publish-Remote Gate -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _allowlist(root, *urls):
|
||||||
|
(root / config.PUBLISH_REMOTES_FILENAME).write_text(
|
||||||
|
json.dumps({"schema": 1, "allowed_push_urls": list(urls)}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_allowlist_means_unrestricted(repo):
|
||||||
|
"""Absence is a legitimate state: a checkout with nothing private in it
|
||||||
|
should not have to declare anything to publish at all."""
|
||||||
|
assert git_publish.read_allowed_push_urls() is None
|
||||||
|
assert git_publish.publish_remote_refusal("origin", "main") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowed_url_passes_the_gate(repo):
|
||||||
|
_allowlist(repo, git_publish.push_url_for("origin"))
|
||||||
|
assert git_publish.publish_remote_refusal("origin", "main") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_refuses_a_remote_not_on_the_list(repo):
|
||||||
|
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
|
||||||
|
_allowlist(repo, git_publish.push_url_for("origin"))
|
||||||
|
refusal = git_publish.publish_remote_refusal("upstream", "main")
|
||||||
|
assert refusal is not None
|
||||||
|
assert "https://example.com/public.git" in refusal
|
||||||
|
assert "Nothing was committed or pushed" in refusal
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_matches_the_url_not_the_remote_name(repo):
|
||||||
|
"""A name-based list would pass a repointed `origin`, which is the failure
|
||||||
|
this gate exists to catch."""
|
||||||
|
_allowlist(repo, "ssh://git@example.com/only-this.git")
|
||||||
|
assert git_publish.publish_remote_refusal("origin", "main") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_reads_pushurl_when_the_remote_sets_one(repo):
|
||||||
|
"""`git push` writes to `pushurl` when present, so that is the value that
|
||||||
|
has to be checked - not the fetch URL beside it."""
|
||||||
|
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
|
||||||
|
_allowlist(repo, "https://example.com/elsewhere.git")
|
||||||
|
assert git_publish.push_url_for("origin") == "https://example.com/elsewhere.git"
|
||||||
|
assert git_publish.publish_remote_refusal("origin", "main") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_refuses_when_the_fetch_url_is_listed_but_the_pushurl_is_not(repo):
|
||||||
|
fetch_url = git_publish.push_url_for("origin")
|
||||||
|
_git(repo, "remote", "set-url", "--push", "origin", "https://example.com/elsewhere.git")
|
||||||
|
_allowlist(repo, fetch_url)
|
||||||
|
assert git_publish.publish_remote_refusal("origin", "main") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_exits_42_and_commits_nothing_when_the_remote_is_refused(repo):
|
||||||
|
_git(repo, "remote", "add", "upstream", "https://example.com/public.git")
|
||||||
|
_allowlist(repo, git_publish.push_url_for("origin"))
|
||||||
|
before = _git(repo, "rev-parse", "HEAD").stdout.strip()
|
||||||
|
(repo / "kb" / "secret.md").write_text("private\n", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(typer.Exit) as excinfo:
|
||||||
|
_publish(remote="upstream")
|
||||||
|
assert excinfo.value.exit_code == EXIT_NEEDS_CLEARANCE
|
||||||
|
|
||||||
|
# Nothing committed, and the file is still sitting there unstaged - the
|
||||||
|
# refusal message promises both. (`git status --porcelain` collapses the
|
||||||
|
# wholly-untracked `kb/` to one entry, so check the index directly.)
|
||||||
|
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == before
|
||||||
|
assert (repo / "kb" / "secret.md").exists()
|
||||||
|
assert "secret.md" not in _git(repo, "ls-files").stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_push_skips_the_gate(repo):
|
||||||
|
"""`--no-push` publishes nowhere, so there is no wrong target to protect
|
||||||
|
against - and a local commit must stay possible."""
|
||||||
|
_allowlist(repo, "ssh://git@example.com/only-this.git")
|
||||||
|
(repo / "kb" / "page.md").write_text("local\n", encoding="utf-8")
|
||||||
|
_publish(push=False)
|
||||||
|
assert "page.md" in _git(repo, "show", "--name-only", "HEAD").stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_allowlist_fails_instead_of_falling_open(repo):
|
||||||
|
"""A broken file must not be read as 'no restriction' - that would turn a
|
||||||
|
corrupted safeguard into a silently disabled one."""
|
||||||
|
(repo / config.PUBLISH_REMOTES_FILENAME).write_text("{not json", encoding="utf-8")
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
git_publish.read_allowed_push_urls()
|
||||||
|
|
||||||
|
|
||||||
|
def test_allowlist_without_a_usable_list_fails(repo):
|
||||||
|
(repo / config.PUBLISH_REMOTES_FILENAME).write_text(
|
||||||
|
json.dumps({"schema": 1, "allowed_push_urls": "not-a-list"}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
with pytest.raises(typer.Exit):
|
||||||
|
git_publish.read_allowed_push_urls()
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_allowlist_refuses_everything(repo):
|
||||||
|
"""An empty list is a deliberate 'publish nowhere', not an oversight that
|
||||||
|
should behave like an absent file."""
|
||||||
|
_allowlist(repo)
|
||||||
|
assert git_publish.publish_remote_refusal("origin", "main") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_has_no_flag_that_opens_it(repo):
|
||||||
|
"""The other two gates clear with a token; this one deliberately does not,
|
||||||
|
because the right fix is a deliberate edit by the user."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
params = inspect.signature(publish_command).parameters
|
||||||
|
assert not any("remote" in name and "confirm" in name for name in params)
|
||||||
|
|||||||
Reference in New Issue
Block a user