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:
2026-09-01 18:06:55 +02:00
parent fb97d46888
commit 7263f85936
10 changed files with 506 additions and 10 deletions
+113
View File
@@ -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)