Files
brain-of-reese/app/core/docs_push.py
T
ducoterra a86caec821
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 173_docs_push_creds_split
All gates green. Final report:

**Phase 173 — final verification pass (all tasks already complete in `complete/`)**

- Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05).
- **Completion criteria:**
  1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM
  2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side
  3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests
  4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py`
  5. Full suite green, coverage >90%, lint clean — ✅
  6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅
  7. Commit + phase move — harness's job (per instructions, not mine).
  8. README/AGENTS consistent — ✅ (below)
- Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors
- Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field).
- No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
2026-10-10 16:47:39 -04:00

204 lines
8.8 KiB
Python

"""Docs-push service (phase 59, task 03).
Commits a document into the configured docs repo and pushes it to the
configured branch. **D3 (owner-locked, 2026-08-31): push only** — the
flow ends at the push to the branch named in ``.env``
(``BOR_DOCS_REPO`` + ``BOR_DOCS_BRANCH``); no PR is ever created or
attempted (no ``gh``, no PR URL, no URL construction, no
GitHub-specific logic). The remote is **generic** — a local path,
``https://``, ``ssh://``, any host — and git is invoked exclusively
through :func:`scripts.git_sync.run_git` (A11: stdlib ``subprocess``
only, no new packages): :func:`push_document` is the only caller of
git for this feature besides :mod:`scripts.git_sync` itself.
The push is a plain ``git push origin <branch>`` — git 2.55 no longer
accepts the ``--ff-only`` flag on ``push`` — and a push without
``-f`` / a ``+`` refspec already refuses non-fast-forward updates
(client-side, against the remote's live refs), so a
concurrently-advanced remote fails loudly: never a force-push, never
a merge. Every failure raises :class:`DocsPushError` carrying git's
stderr (the ``GitSyncError`` style), and the checkout under
``work_dir`` is left as-is for inspection.
"""
from __future__ import annotations
import os
from pathlib import Path
from scripts.git_sync import GitSyncError, run_git
__all__ = ["DocsPushError", "push_document"]
# Fixed per-invocation commit identity (phase 59 ASSUMPTION) — passed
# with ``-c`` on the commit itself, so the push never relies on the
# machine's global git config. ``commit.gpgsign=false`` keeps a
# headless server from ever prompting for a GPG pinentry (this repo's
# own no-GPG-signing policy).
_COMMIT_NAME = "Brain of Reese"
_COMMIT_EMAIL = "bor@local"
class DocsPushError(RuntimeError):
"""A docs push failed (or git is missing); carries git's stderr."""
def _refuse_unsafe_rel_path(rel_path: str) -> None:
"""Defensively re-assert the upstream path guard-rails (task 02).
The file is written inside the docs checkout, so a blank path, an
absolute path, or any ``.``/``..`` component is refused here as
well — even though the drafts API already guard-railed the value.
"""
rel = Path(rel_path)
if not rel_path.strip() or rel.is_absolute() or any(part in (".", "..") for part in rel.parts):
raise DocsPushError(f"refusing unsafe rel_path: {rel_path!r}")
def push_document(
repo: str,
base_branch: str,
branch: str,
work_dir: str,
rel_path: str,
content: str,
commit_message: str,
user: str = "",
token: str = "",
) -> tuple[str, str]:
"""Commit *content* at *rel_path* and push (ff-only) to *branch*.
*user* / *token* are the docs-push credentials (phase 173, C2):
when *token* is non-blank, EVERY git step of the sequence runs with
git's documented HTTP credential environment —
``GIT_CURL_USERNAME`` / ``GIT_CURL_PASSWORD`` (+
``GIT_TERMINAL_PROMPT=0`` so a headless server fails fast instead
of hanging on a prompt) — merged over ``os.environ`` (the
subprocess env-replacement trap: a dict REPLACES the parent
environment, so the merge is what keeps ``PATH`` and friends).
The credential travels only in that environment — it never appears
in the argv (the clean *repo* URL stays in the argv untouched),
so it is absent from the process list. ``GIT_CURL_*`` is inert for
``ssh://`` / local-path remotes (accepted — the remote decides).
**C5:** when *token* is blank (the default — an anonymous push to
a public or credential-free remote, exactly the pre-phase state),
every ``run_git`` call keeps ``env=None`` and the invocations are
byte-identical to the pre-phase ones.
Step sequence (each step via :func:`scripts.git_sync.run_git`;
``cwd=work_dir`` once the checkout exists):
1. Ensure the checkout: without ``.git`` →
``git clone --depth 1 --branch <base_branch> <repo> <work_dir>``
(the base branch is explicit — the remote's default may differ).
2. ``git fetch --depth 1 origin <base_branch>`` — re-sync the base
before every push. The fetched base tip (``FETCH_HEAD``) is
captured immediately — a later *failed* fetch clears
``FETCH_HEAD``.
3. ``git fetch --depth 100 origin <branch>`` — **failure is
expected** while the branch does not exist on the remote yet
(swallowed and continued); on success the fetched branch tip is
captured the same way.
4. Attach the local branch:
- a local branch left by a previous push → ``git checkout
<branch>`` — keep its own history, so a
concurrently-advanced remote then fails the push loudly in
step 7 instead of being silently re-based or merged;
- otherwise ``git checkout -B <branch>`` onto the captured
branch tip (re-attach onto the previously pushed branch — its
history, so the push can fast-forward) or, on first push,
onto the captured base tip (a new branch from the fresh
base). The captured shas are used instead of symbolic refs:
git's local transport (a local-path remote) does not create
``refs/remotes/origin/<branch>`` for a newly fetched branch,
and a failed fetch clears ``FETCH_HEAD``.
5. Write the file (parent dirs created;
:func:`_refuse_unsafe_rel_path` re-asserts the parts check
defensively).
6. ``git add -- <rel_path>`` + ``commit -m <commit_message>`` with
the fixed per-invocation identity (``-c user.name`` /
``-c user.email`` — no reliance on global git config).
7. ``git push origin <branch>`` — creates the remote branch on
first push; a concurrently-advanced remote is refused
non-fast-forward (never a force-push, never a merge).
8. ``sha = git rev-parse HEAD``; return ``(branch, sha)``.
Raises :class:`DocsPushError` (git's stderr in the message) on any
failure — the checkout is left as-is for inspection.
"""
_refuse_unsafe_rel_path(rel_path)
work = Path(work_dir).expanduser()
# C5: blank token → env stays None → every invocation below is
# byte-identical to the pre-phase one.
git_env: dict[str, str] | None = None
if token.strip():
# C2: git's documented HTTP credential env — never in the argv.
# GIT_TERMINAL_PROMPT=0 fails fast instead of hanging the headless
# server on a credential prompt.
git_env = {
**os.environ,
"GIT_CURL_USERNAME": user,
"GIT_CURL_PASSWORD": token,
"GIT_TERMINAL_PROMPT": "0",
}
try:
if not (work / ".git").exists():
work.parent.mkdir(parents=True, exist_ok=True)
run_git(
[
"git",
"clone",
"--depth",
"1",
"--branch",
base_branch,
repo,
str(work),
],
cwd=work.parent,
env=git_env,
)
run_git(["git", "fetch", "--depth", "1", "origin", base_branch], cwd=work, env=git_env)
# Capture the fetched base tip now — a *failed* fetch clears
# FETCH_HEAD, so the symbolic ref cannot be reused later.
base_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work, env=git_env).strip()
try:
run_git(
["git", "fetch", "--depth", "100", "origin", branch], cwd=work, env=git_env
)
branch_sha = run_git(
["git", "rev-parse", "FETCH_HEAD"], cwd=work, env=git_env
).strip()
except GitSyncError:
branch_sha = None # the branch does not exist on the remote yet
if run_git(["git", "branch", "--list", branch], cwd=work, env=git_env).strip():
run_git(["git", "checkout", branch], cwd=work, env=git_env)
else:
anchor = branch_sha if branch_sha is not None else base_sha
run_git(["git", "checkout", "-B", branch, anchor], cwd=work, env=git_env)
target = work / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
run_git(["git", "add", "--", rel_path], cwd=work, env=git_env)
run_git(
[
"git",
"-c",
f"user.name={_COMMIT_NAME}",
"-c",
f"user.email={_COMMIT_EMAIL}",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
commit_message,
],
cwd=work,
env=git_env,
)
run_git(["git", "push", "origin", branch], cwd=work, env=git_env)
sha = run_git(["git", "rev-parse", "HEAD"], cwd=work, env=git_env).strip()
except GitSyncError as err:
raise DocsPushError(str(err)) from err
return branch, sha