feat: Publish-Remote Gate und die Anleitung fuer eine private Instanz (2.2.0)
CI / verify (push) Successful in 44s
Release / release (push) Successful in 36s

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:
torben committed 2026-09-01 18:06:55 +02:00
1 parent fb97d46888
commit 7263f85936
10 files changed
+506 -10

No files matched your search

+38 -1
View File
@@ -21,7 +21,7 @@ import typer
from rich.console import Console
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.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")
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:
missing = [
rel_path(path)
@@ -355,6 +391,7 @@ def run_doctor() -> list[Check]:
check_structure(),
check_personalization(),
check_environment(),
check_publish_remotes(),
check_generated_files(),
check_session_id(),
]
+100
View File
@@ -80,6 +80,86 @@ def _run(args: list[str]) -> subprocess.CompletedProcess:
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]]:
"""Parse `git status --porcelain -z` output into (status_code, path) pairs.
@@ -919,6 +999,26 @@ def publish_command(
if push and 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
# 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