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:
@@ -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
|
||||
|
||||
@@ -70,6 +70,22 @@ ENVIRONMENT_TEMPLATE = f"{ENVIRONMENT_FILE}.template"
|
||||
# `dist export` already computes (AGENTS.md invariant 8). See 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:
|
||||
"""The author to stamp a new source page with, per instance.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
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)
|
||||
assert change.status == "modified"
|
||||
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