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:
1 parent
fb97d46888
commit
7263f85936
10 files changed
+506
-10
No files matched your search
@@ -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(),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user