Merge pull request 'Gitea working surface: MCP, branch protection, runbook, and two protocol rules' (#10) from agents/gitea-mcp into main
Some checks failed
CI / WASM — Web (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Native — linux (push) Has been cancelled

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-09-07 19:57:55 +00:00
13 changed files with 810 additions and 75 deletions

View File

@@ -10,36 +10,53 @@ env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# ── What this file may assume about where it runs ────────────────────────────
#
# It runs on ONE self-hosted runner: `rpi5-runner`, aarch64, advertising
# ["ubuntu-latest", "ubuntu-24.04", "ubuntu-22.04"]. Nothing else exists.
#
# This file was written for GitHub's hosted fleet — three operating systems and
# x86_64 throughout — and had never once gone green here: 23 runs cancelled, 2
# waiting, zero successes. Two separate reasons, and both are configuration
# describing a world that is not this one:
#
# * `windows-latest` / `macos-latest` match no runner label, so those jobs sit
# in WAITING for ever. The run therefore never reaches a terminal state, and
# a pull request's checks never resolve either way — not red, just never
# finished. That is worse than a failure: a red check tells you something.
# * `--target x86_64-unknown-linux-gnu` on an aarch64 host makes every build a
# cross-compile, and `wayland-sys`'s build script dies on it —
# "pkg-config has not been configured to support cross-compilation".
#
# So: one job, on the machine that exists, building for the machine that exists.
# If a second architecture is ever wanted here it needs a second RUNNER, not a
# second matrix row.
jobs:
# ── Native builds: Windows, macOS, Linux ────────────────────────────────────
# ── Native build, on the one runner there is ────────────────────────────────
native:
name: Native — ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: macos-latest
target: aarch64-apple-darwin
name: Native — linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
# `stable` installs a MINIMAL profile: rustc, cargo, rust-std and no
# more. Components have to be named. Without this line the Clippy step
# below dies on "'cargo-clippy' is not installed for the toolchain
# 'stable-aarch64-unknown-linux-gnu'" — which is not a lint result, it
# is the step never having run. The `fmt` job below always got this
# right; this one never did.
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
components: clippy
- name: Cache Cargo registry and build
uses: Swatinem/rust-cache@v2
# Linux: install Bevy's system dependencies (X11, Wayland, audio)
- name: Install Linux system dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
@@ -52,16 +69,30 @@ jobs:
pkg-config
- name: Check (fast compile check)
run: cargo check --workspace --target ${{ matrix.target }}
run: cargo check --workspace
- name: Build (debug)
run: cargo build --workspace --target ${{ matrix.target }}
run: cargo build --workspace
- name: Run tests
run: cargo test --workspace --target ${{ matrix.target }}
run: cargo test --workspace
# This step has never once executed on this codebase: the toolchain above
# shipped without the component, so every run died on "not installed"
# before clippy saw a line of source. Its result was never pass or fail,
# only unmeasured. With the component installed it becomes a real check,
# and the first honest thing it will report is that the workspace is not
# clean — the build already emits ~13 plain rustc warnings (unused
# imports, unused variables, needless `mut`, dead fields) that
# `-D warnings` promotes to errors, before clippy's own lints are counted.
#
# Left gating on purpose. A red check that measures something is worth
# more than a green one that measures nothing, and the alternative —
# `continue-on-error`, or dropping `-D warnings` — cannot tell "debt not
# yet paid" from "debt paid", which is the shape PROTOCOL.md forbids.
# The debt is scoped in #13, as the rustfmt debt is in #12.
- name: Clippy
run: cargo clippy --workspace --target ${{ matrix.target }} -- -D warnings
run: cargo clippy --workspace -- -D warnings
# ── WASM / Web build ─────────────────────────────────────────────────────────
wasm:

View File

@@ -82,6 +82,24 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
# ── gitea-mcp ────────────────────────────────────────────────────────────────
# The agent's hands on issues, pull requests and notifications — Gitea's own MCP
# server, so there is no second store of truth to drift out of sync with the
# first.
#
# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is
# handed a token that can write to the repository. The checksum is the one
# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset.
ARG GITEA_MCP_VERSION=1.7.0
ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d
RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \
"https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \
&& echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \
&& chmod +x /usr/local/bin/gitea-mcp \
&& rm -f /tmp/gitea-mcp.tar.gz \
&& gitea-mcp --version
# ── The agent user ───────────────────────────────────────────────────────────
# NOT root, and not negotiable: Claude Code refuses --dangerously-skip-permissions
# when it has root privileges. uid/gid 1000 matches the host account so files

View File

@@ -208,6 +208,54 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER"
"$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true
chmod 600 "$HOME/.claude.json" 2>/dev/null || true
# ── The Gitea MCP server ─────────────────────────────────────────────────────
# Registered at USER scope rather than from a committed `.mcp.json`: the token
# differs per agent and none of it belongs in git.
#
# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat
# …)` would write the secret in cleartext into ~/.claude.json, where it is read
# by every session in this container and lands in any copy of that file.
# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its
# read-only mount and lets the server read it itself.
#
# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name
# that already exists, and ~/.claude.json is re-seeded above — neither ordering
# survives alone.
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}"
GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}"
# Which tools this agent gets. Deliberately not all of them:
#
# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one.
# Gitea will not let an author approve its own pull request — but the moment
# the two agents are separate people, nothing stops them approving each
# OTHER's and satisfying `required_approvals` between themselves with no
# human involved. Separate identities open that hole; withholding the tool
# closes it here, and the approvals whitelist on `main` closes it there.
# * the file / branch / repo WRITE tools are absent: a change reaches `main`
# as a reviewable commit through git, or it does not reach it.
#
# `pull_request_write` bundles `merge` into one tool and cannot be split, so
# merging stays blocked where the agent cannot reach it — the merge whitelist in
# branch protection. This list is defence in depth BEHIND that, never instead.
GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-get_me,notification_read,notification_write,list_issues,issue_read,issue_write,attachment_read,search_issues,label_read,milestone_read,list_pull_requests,pull_request_read,pull_request_write}"
if [ ! -s "$GITEA_TOKEN_FILE" ]; then
echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered."
echo "[entrypoint] This agent cannot read its notifications or open a pull"
echo "[entrypoint] request, which is most of what its brief asks of it."
elif ! command -v gitea-mcp >/dev/null 2>&1; then
echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2
else
claude mcp remove gitea -s user >/dev/null 2>&1 || true
if claude mcp add -s user gitea \
-e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
-- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then
echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL"
else
echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2
echo "[entrypoint] no pull requests and no notifications." >&2
fi
fi
# ── Claude Code ──────────────────────────────────────────────────────────────
if [ "${SYLPH_AUTONOMOUS:-0}" = "1" ]; then
# Drop the image's default CMD first, or `claude` is handed the literal string

View File

@@ -23,6 +23,8 @@
# SYLPH_REMOTE_NAME Remote Control session name (default: sylpheed-agent)
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
# (default: $HOME/.sylph-git-credentials)
# SYLPH_GITEA_TOKEN this agent's own Gitea token file
# (default: $HOME/.sylph-gitea-token-decoder)
# SYLPH_LOOP_INTERVAL fixed loop cadence, e.g. 30m (default: 45m)
# SYLPH_CPUS / SYLPH_MEM_GB override the computed half
set -euo pipefail
@@ -229,6 +231,29 @@ docker_args() {
echo " or point SYLPH_GIT_CREDENTIALS elsewhere." >&2
fi
# ── Gitea ──
# This agent's OWN token, for its OWN Gitea account — not the push credential
# and not the human's. Three reasons it is separate: `~/.sylph-git-credentials`
# is scoped `write:repository` and every issue endpoint REFUSES it; a pull
# request the agent authored is one a human can approve, which is the entire
# review gate; and revoking one agent then touches neither the other nor you.
#
# Mounted read-only and passed to the MCP server BY PATH — see the entrypoint
# for why the value must not go through the environment.
# Inert until the file exists: the container still runs, with no issues.
GITEATOK="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-decoder}"
if [ -f "$GITEATOK" ]; then
_out+=(
-v "$GITEATOK:/sylph-home/re/.sylph-gitea-token:ro"
-e "GITEA_TOKEN_FILE=/sylph-home/re/.sylph-gitea-token"
)
else
echo "==> NOTE: no Gitea token at $GITEATOK — this agent cannot read its" >&2
echo " notifications, open an issue or open a pull request. Generate one" >&2
echo " while logged in AS sylph-decoder: Settings -> Applications, scopes" >&2
echo " write:repository, write:issue, write:notification, read:user." >&2
fi
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
[ -n "${SYLPH_VULKAN:-}" ] && _out+=(-e "SYLPH_VULKAN=$SYLPH_VULKAN")
[ -n "${SYLPH_REMOTE:-}" ] && _out+=(-e "SYLPH_REMOTE=$SYLPH_REMOTE")

View File

@@ -62,6 +62,24 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& npm cache clean --force \
&& rm -rf /var/lib/apt/lists/*
# ── gitea-mcp ────────────────────────────────────────────────────────────────
# The agent's hands on issues, pull requests and notifications — Gitea's own MCP
# server, so there is no second store of truth to drift out of sync with the
# first.
#
# PINNED AND CHECKSUMMED, not "whatever is at that URL today": this binary is
# handed a token that can write to the repository. The checksum is the one
# published in `gitea-mcp_1.7.0_checksums.txt` for the Linux x86_64 asset.
ARG GITEA_MCP_VERSION=1.7.0
ARG GITEA_MCP_SHA256=bbc9a7b462facd3c56b1558ee6054e91f2fca27a2878b5599afddcf57d446b8d
RUN curl -fsSL -o /tmp/gitea-mcp.tar.gz \
"https://gitea.com/gitea/gitea-mcp/releases/download/v${GITEA_MCP_VERSION}/gitea-mcp_Linux_x86_64.tar.gz" \
&& echo "${GITEA_MCP_SHA256} /tmp/gitea-mcp.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/gitea-mcp.tar.gz -C /usr/local/bin gitea-mcp \
&& chmod +x /usr/local/bin/gitea-mcp \
&& rm -f /tmp/gitea-mcp.tar.gz \
&& gitea-mcp --version
# ── The agent user ───────────────────────────────────────────────────────────
# NOT root: Claude Code refuses --dangerously-skip-permissions with root
# privileges. Ubuntu 24.04 ships its own `ubuntu` account at uid 1000, so the

View File

@@ -87,6 +87,54 @@ python3 /usr/local/bin/seed-claude-config.py "$HOME/.claude.json" "$CLAUDE_VER"
"$PWD" "${PROJECT_DIR:-/work}" "$HOME" || true
chmod 600 "$HOME/.claude.json" 2>/dev/null || true
# ── The Gitea MCP server ─────────────────────────────────────────────────────
# Registered at USER scope rather than from a committed `.mcp.json`: the token
# differs per agent and none of it belongs in git.
#
# 🔴 THE TOKEN IS PASSED AS A PATH, NOT A VALUE. `-e GITEA_ACCESS_TOKEN=$(cat
# …)` would write the secret in cleartext into ~/.claude.json, where it is read
# by every session in this container and lands in any copy of that file.
# `GITEA_ACCESS_TOKEN_FILE` (gitea-mcp ≥ 1.7.0) leaves the token in its
# read-only mount and lets the server read it itself.
#
# Re-registered on every start, remove-then-add: `claude mcp add` refuses a name
# that already exists, and ~/.claude.json is re-seeded above — neither ordering
# survives alone.
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}"
GITEA_HOST_URL="${SYLPH_GITEA_HOST:-https://git.mc02.dev}"
# Which tools this agent gets. Deliberately not all of them:
#
# * `pull_request_review_write` IS ABSENT, and that is the load-bearing one.
# Gitea will not let an author approve its own pull request — but the moment
# the two agents are separate people, nothing stops them approving each
# OTHER's and satisfying `required_approvals` between themselves with no
# human involved. Separate identities open that hole; withholding the tool
# closes it here, and the approvals whitelist on `main` closes it there.
# * the file / branch / repo WRITE tools are absent: a change reaches `main`
# as a reviewable commit through git, or it does not reach it.
#
# `pull_request_write` bundles `merge` into one tool and cannot be split, so
# merging stays blocked where the agent cannot reach it — the merge whitelist in
# branch protection. This list is defence in depth BEHIND that, never instead.
GITEA_MCP_TOOLS="${SYLPH_GITEA_TOOLS:-get_me,notification_read,notification_write,list_issues,issue_read,issue_write,attachment_read,search_issues,label_read,milestone_read,list_pull_requests,pull_request_read,pull_request_write}"
if [ ! -s "$GITEA_TOKEN_FILE" ]; then
echo "[entrypoint] no Gitea token at $GITEA_TOKEN_FILE — MCP not registered."
echo "[entrypoint] This agent cannot read its notifications or open a pull"
echo "[entrypoint] request, which is most of what its brief asks of it."
elif ! command -v gitea-mcp >/dev/null 2>&1; then
echo "[entrypoint] gitea-mcp is not in this image — rebuild it." >&2
else
claude mcp remove gitea -s user >/dev/null 2>&1 || true
if claude mcp add -s user gitea \
-e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
-- gitea-mcp -t stdio -H "$GITEA_HOST_URL" -O "$GITEA_MCP_TOOLS" >/dev/null 2>&1; then
echo "[entrypoint] gitea MCP registered against $GITEA_HOST_URL"
else
echo "[entrypoint] gitea MCP registration FAILED — the agent has no issues," >&2
echo "[entrypoint] no pull requests and no notifications." >&2
fi
fi
# ── The repository, cloned into THIS AGENT'S OWN volume ─────────────────────
# Not a bind mount of a human's working tree. That arrangement bit this project
# three times: an agent's `git config --local` captured a human's commits, a

View File

@@ -14,6 +14,8 @@
# SYLPH_PORT_REPO repo to mount at /work (default: this script's parent)
# SYLPH_DISC extracted disc root
# SYLPH_GIT_CREDENTIALS file with `https://<user>:<token>@host` for push-work
# SYLPH_GITEA_TOKEN this agent's own Gitea token file
# (default: $HOME/.sylph-gitea-token-port)
# SYLPH_LOOP_INTERVAL fixed loop cadence (default 45m)
#
# ── Two hard-won constraints ────────────────────────────────────────────────
@@ -133,6 +135,29 @@ docker_args() {
echo " so its work dies with the container." >&2
fi
# ── Gitea ──
# This agent's OWN token, for its OWN Gitea account — not the push credential
# and not the human's. Three reasons it is separate: `~/.sylph-git-credentials`
# is scoped `write:repository` and every issue endpoint REFUSES it; a pull
# request the agent authored is one a human can approve, which is the entire
# review gate; and revoking one agent then touches neither the other nor you.
#
# Mounted read-only and passed to the MCP server BY PATH — see the entrypoint
# for why the value must not go through the environment.
# Inert until the file exists: the container still runs, with no issues.
local giteatok="${SYLPH_GITEA_TOKEN:-$HOME/.sylph-gitea-token-port}"
if [ -f "$giteatok" ]; then
_out+=(
-v "$giteatok:/sylph-home/port/.sylph-gitea-token:ro"
-e "GITEA_TOKEN_FILE=/sylph-home/port/.sylph-gitea-token"
)
else
echo "==> NOTE: no Gitea token at $giteatok — this agent cannot read its" >&2
echo " notifications, open an issue or open a pull request. Generate one" >&2
echo " while logged in AS sylph-port: Settings -> Applications, scopes" >&2
echo " write:repository, write:issue, write:notification, read:user." >&2
fi
[ -n "${ANTHROPIC_API_KEY:-}" ] && _out+=(-e "ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY")
# ── GPU ──

View File

@@ -9,15 +9,25 @@ Steps are marked **👤 you** (a decision or a credential only you can make) or
## Where things stand
*`main` carries the play-tested port work and the rewritten briefs (`cd3a81af`).
* ✅ The briefs already describe this workflow. **They describe tooling that does
not exist yet** — which is why the agents are stopped and must stay stopped
until Phase 7.
* ❌ Nothing exists on the instance: no agent users, no API tokens, no labels, no
branch protection, no MCP.
**Updated 2026-09-04, against the live instance.** Phases 14 and 6 are done.
⚠️ **Do not start an agent before Phase 7.** Its brief will tell it to read
notifications and open issues, and it will have no tool that can.
| phase | state |
|---|---|
| 1 · identities | ✅ `sylph-decoder`, `sylph-port`, both collaborators at **Write** |
| 2 · protection | ✅ applied and **verified behaviourally** — a real push to `main` was refused with `pre-receive hook declined`, as the repository owner |
| 3 · tokens | ✅ three, each functionally probed: right identity, `403` on `branch_protections` for both agents |
| 4 · labels | ✅ 11 labels, 4 milestones, idempotence confirmed by a second run creating nothing |
| 5 · MCP | ⏳ **written and merged; the images are NOT rebuilt.** This is the remaining blocker |
| 6 · items | ✅ 9 issues seeded with 3 dependency edges, read back. All `state/proposed`**awaiting the human's approval of the shapes** |
| 7 · restart | ⏳ after the rebuild |
⚠️ **Do not start an agent before Phase 7.** Until the images are rebuilt, the
briefs tell it to read notifications and open issues with no tool that can.
📌 **This block goes stale first.** It was already wrong once — it read "nothing
exists on the instance" while nine issues were live. If it disagrees with
`gitea-protect --verify` or the issue list, believe those: they measure, this
remembers.
---
@@ -57,7 +67,34 @@ agent remove the rule that stops it merging.
**Do this before the agents hold tokens**, so there is no window in which they
can push to `main`.
Repo → Settings → Branches → *Protected Branches* → add rule for `main`:
**Apply it through the API, not the form**`tools/gitea-protect`. Six settings
of which two are load-bearing, and both of those were missing from the first
draft of this phase: that is the shape of thing that gets mis-clicked. An API
call is reviewable in a diff and repeatable, and the same file re-checks it later.
```bash
tools/gitea-protect --dry-run # the exact rule, no credential read
tools/gitea-protect # create or update, then verify
tools/gitea-protect --verify # assert it still holds; exit 1 if not
```
📌 **Run it on the agent box, not the Pi.** Branch protection is a
repository-scope endpoint, so `~/.sylph-gitea-api-token` cannot do it — that
token is deliberately issue-only. The credential that can is the one already
sitting on that machine, `~/.sylph-git-credentials`, which the tool reads. Doing
it there means no new credential, and no second machine holding push rights just
to close a one-time setup step.
🔴 The tool sets `block_admin_merge_override: false`, deliberately. Turning it on
would lock **you** out of your own work — approvals are whitelisted to `fabi`,
Gitea will not let `fabi` approve a `fabi` PR, so a human-authored PR could never
reach one approval and could never merge. The admin override is what keeps that
door open, and it is not a hole in the agent gate for exactly one reason: the
agents are **Write, not Admin**. That is what Phase 1.2 is buying, and this is
where it gets spent.
Or by hand — Repo → Settings → Branches → *Protected Branches* → add rule for
`main`:
| setting | value | why |
|---|---|---|
@@ -65,6 +102,46 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`:
| Require approvals | **1** | the human gate, made native |
| Dismiss stale approvals | **on** | an approval must describe the code that merges |
| Block merge on rejected reviews | **on** | "changes requested" has to mean something |
| Enable Merge Whitelist | **on** → whitelist **`fabi` only** | approvals are not the last gate. *Merging* is |
| Enable Approvals Whitelist | **on** → whitelist **`fabi` only** | only a human's approval counts toward the 1 |
### 🔴 The hole that separate identities open, and why the last two rows close it
Phase 1 makes the agents distinct people so that a human *can* approve their
work. The same change makes something else possible for the first time: **Gitea
refuses to let an author approve their own pull request — it does not stop
`sylph-decoder` approving `sylph-port`'s.** With `required_approvals = 1` and
nothing else, the two agents satisfy the human gate between themselves, and the
author can then press Merge, because branch protection blocks *pushes* to `main`
and never blocked *merges*.
Neither whitelist is decoration, and neither replaces the other:
* **approvals whitelist** — an agent's approval stops counting toward the 1.
* **merge whitelist** — even a legitimately approved PR is merged by you.
Withholding the review tool from the agents (Phase 5) is defence in depth behind
these, not a substitute: an agent still has a browser-shaped API token.
### 🔴 What this rule does not gate, said plainly
It binds everyone who reaches Gitea through the API or the web. **It does not
bind anyone who can run `gitea admin` inside the container** — and that includes
the supervising agent on the Pi, the one that created the agent accounts and
minted their tokens. From that shell you can issue an admin token or edit this
rule, and nothing here would stop you.
That is not a hole to plug here; it is the boundary of what Phase 2 buys, and it
should be written down rather than discovered. **Phases 1 and 2 gate the two
containerised agents** — the ones that run unattended on a loop, whose whole
design assumption is that policy lives somewhere they cannot reach. A supervisor
with a shell on the host is not in that set, and the protection above should not
be read as universal.
The distinction is exactly the one Phase 1.2 draws with **Write, not Admin**: the
looping agents get a permission level that cannot edit the rule that binds them.
`tools/gitea-protect --verify` asserts that level on every run, which is the
check that keeps this true rather than merely stated.
> ### Check — and actually run it, do not assume it
>
@@ -74,10 +151,35 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`:
> 1. As `sylph-port`, push a throwaway branch and open a PR into `main`.
> 2. Confirm **no Merge button** is offered to that account.
> 3. Confirm **you** can approve it, and that *it* cannot approve itself.
> 4. Close the PR, delete the branch.
> 4. Approve it yourself, then look at `sylph-port` again: **still no Merge
> button**, now that an approval exists. This is the step that tests the
> merge whitelist rather than the absence of an approval — without it, steps
> 2 and 3 pass on an instance where the agents can merge each other's work.
> 5. As **yourself**, try `git push origin main` with a throwaway commit. It
> should be **refused** — see below.
> 6. Close the PR, delete the branch, drop the commit.
>
> If step 2 offers a Merge button, stop — the rest of this runbook assumes it
> does not.
> If step 2 or step 4 offers a Merge button, stop — the rest of this runbook
> assumes neither does.
### ⚠️ Your own pushes to `main` stop too
Not a side effect — the rule working. `enable_push: false` compiles to
`CanUserPush`, which in Gitea's `models/git/protected_branch.go` returns false
with **no bypass for repository admins or the owner**:
```go
if !protectBranch.CanPush {
return false
}
```
Three commits reached `main` by direct push on the day this was written, so the
first time you notice will be the first time you reach for it. From Phase 2 on,
**human changes go through pull requests like everything else** — and merging
them is what the admin override above is for. `--verify` asserts this state
rather than tolerating it: a verifier that excused your push would be excusing
the gate.
---
@@ -86,11 +188,21 @@ Repo → Settings → Branches → *Protected Branches* → add rule for `main`:
Three principals, three tokens. Settings → Applications → *Generate New Token*
while logged in **as that user**.
| whose | scopes | goes in |
|---|---|---|
| **you** (`fabi`) | `write:issue`, `read:repository` | `~/.sylph-gitea-api-token` |
| `sylph-decoder` | `write:repository`, `write:issue`, `write:notification`, `read:user` | `~/.sylph-gitea-token-decoder` |
| `sylph-port` | same four | `~/.sylph-gitea-token-port` |
| whose | scopes | goes in | on which machine |
|---|---|---|---|
| **you** (`fabi`) | `write:issue`, `read:repository` | `~/.sylph-gitea-api-token` | **the Pi** |
| `sylph-decoder` | `write:repository`, `write:issue`, `write:notification`, `read:user` | `~/.sylph-gitea-token-decoder` | the agent box |
| `sylph-port` | same four | `~/.sylph-gitea-token-port` | the agent box |
📌 **Three machines, and the split is by tooling, not by capability.** Gitea runs
on the Pi, published through a VPS — so `git.mc02.dev` resolves to a hosted
address and a DNS lookup tells you nothing about the origin. The agent
containers run on the x86_64 desktop, which reaches the Gitea API perfectly well
(`GET /api/v1/version``200 {"version":"1.25.5"}`, run from there).
The `fabi` token lives on the Pi because that is where `tools/gitea-setup` runs,
and that is where the session driving Phases 4 and 6 sits. It is **not** a
reachability constraint, and an earlier draft that said so was wrong.
```bash
printf '%s\n' '<token>' > ~/.sylph-gitea-api-token && chmod 600 ~/.sylph-gitea-api-token
@@ -104,6 +216,14 @@ containers, exactly like `~/.sylph-claude-token`.
`required=[read:issue], token scope=write:repository`. It stays as it is; these
are additional.
🔴 **Do not add `write:repository` to the `fabi` token**, even though Phase 2's
API path might look as though it needs it. **A `write:repository` token *is* a
push credential** — that is the scope git checks for receive-pack — so adding it
would give the Pi push rights over `main`, in order to avoid giving the Pi push
rights. `gitea-protect` sidesteps it entirely by running on the agent box
against the credential already there. This warning exists because that advice
was given, in chat, by the same author as this file.
> **Check:** `tools/gitea-setup --dry-run` prints "would create …" rather than a
> scope error.
@@ -116,7 +236,7 @@ tools/gitea-setup --dry-run # read it first
tools/gitea-setup # idempotent; safe to re-run
```
Creates 12 labels (the `state/*` set, `agent/*`, `kind/*`) and 4 milestones
Creates 11 labels — 5 `state/*`, 2 `agent/*`, 4 `kind/*` and 4 milestones
(Menus, Title screen, Graphics pipeline, Infrastructure).
**No Kanban board yet, on purpose.** Gitea's board does not follow labels, so it
@@ -132,34 +252,72 @@ filter turns out to be insufficient.
## Phase 5 · The MCP server 🤖
`gitea-mcp` **v1.7.0**`gitea-mcp_Linux_x86_64.tar.gz` from
`https://gitea.com/gitea/gitea-mcp/releases`. Confirmed flags: `-t stdio`,
`-H <host>`, token via `GITEA_ACCESS_TOKEN`.
**Done — in the tree, not yet in an image.** `gitea-mcp` **v1.7.0**, Linux
x86_64, sha256 `bbc9a7b4…d446b8d` from the release's own `checksums.txt`. The
flags are no longer taken on trust: the arm64 build of the same release was run
and its `--help` read, so `-t stdio`, `-H <url>`, `-O/--tools`, `-S/--scope`,
`-r/--read-only` and `GITEA_ACCESS_TOKEN_FILE` are confirmed, not assumed.
Three edits per image, which I make:
Three edits per image, made:
1. **`Dockerfile`** — fetch and unpack the release binary to `/usr/local/bin`,
pinned to v1.7.0 with a checksum.
2. **`entrypoint.sh`** — register it for that agent's own identity:
1. **`Dockerfile`** — fetch the release tarball, verify the checksum, unpack
`gitea-mcp` into `/usr/local/bin`, and run `--version` at build time so a bad
pin fails the build rather than the agent.
2. **`entrypoint.sh`** — register it at user scope for that agent's identity,
remove-then-add so a restart is idempotent:
```bash
claude mcp add -s user gitea -e "GITEA_ACCESS_TOKEN=$(cat "$GITEA_TOKEN_FILE")" \
-- gitea-mcp -t stdio -H https://git.mc02.dev
claude mcp add -s user gitea -e "GITEA_ACCESS_TOKEN_FILE=$GITEA_TOKEN_FILE" \
-- gitea-mcp -t stdio -H https://git.mc02.dev -O "$GITEA_MCP_TOOLS"
```
User scope, not a committed `.mcp.json` — the token differs per agent and none
of it belongs in git.
3. **`sylph-decoder` / `sylph-port`** — mount the matching token file read-only.
🔴 **By path, not by value.** The earlier draft of this line read
`GITEA_ACCESS_TOKEN=$(cat …)`, which writes the token in cleartext into
`~/.claude.json` — read by every session in the container and carried into any
copy of that file. `GITEA_ACCESS_TOKEN_FILE` is new in the version we pin and
leaves the secret in its read-only mount.
3. **`sylph-decoder` / `sylph-port`** — mount `~/.sylph-gitea-token-{decoder,port}`
read-only and pass its path. Inert until the file exists: without a token the
container still starts, says plainly that the agent has no issues and no pull
requests, and carries on.
Then rebuild both images. ⚠️ `CARGO_BUILD_JOBS=4` and limited `-j`; a
full-parallel build has OOM-crashed this box.
**👤 Yours:** rebuild both images on the agent box, where the containers run.
⚠️ `CARGO_BUILD_JOBS=4` and a limited `-j`; a full-parallel build has OOM-crashed
that machine.
📌 **Worth trying, unverified:** the server takes `--tools` / `GITEA_TOOLS` to
filter which tools it exposes. If a merge tool can be excluded by name, that is
defence in depth behind Phase 2 — belt *and* braces. I have not confirmed the
tool names, so this is an experiment at install time, **not** a substitute for
branch protection.
```bash
docker/decoder/sylph-decoder build
docker/port/sylph-port build
```
### The tool filter is a control now, not an experiment
The tool names were unknown when this was written; they are in the release's
README, and the set each agent gets is pinned in the entrypoint
(`SYLPH_GITEA_TOOLS` overrides it):
```
get_me, notification_read, notification_write, list_issues, issue_read,
issue_write, attachment_read, search_issues, label_read, milestone_read,
list_pull_requests, pull_request_read, pull_request_write
```
What is **absent** is the point:
* **`pull_request_review_write`** — the tool that approves, dismisses and
resolves reviews. Without it an agent cannot approve the *other* agent's pull
request through the MCP. Pair it with the approvals whitelist in Phase 2; the
whitelist is the control, this is the layer in front of it.
* **the file, branch, tag and repo write tools** — a change reaches `main` as a
reviewable commit through git, or it does not reach it.
* `label_write` / `milestone_write` — agents *apply* labels (that is
`issue_write`); they do not get to redefine the state machine.
`pull_request_write` bundles `merge` into one action-based tool and **cannot be
split**, which is exactly why merging is blocked by the merge whitelist instead.
> **Check:** in each container, `claude mcp list` shows `gitea` connected, and a
> read call returns this repo's labels.
> read call returns this repo's labels. The entrypoint also says which of the two
> it did on every start, so a missing token is visible in `logs` rather than as
> an agent quietly improvising.
---
@@ -211,8 +369,10 @@ Not blockers for Phase 7, but the workflow is not finished without them:
existing refusals stay: no `main`, no force-push.
* **an attachment uploader** — the MCP exposes `attachment_read` only, so putting
a screenshot on an issue needs a direct `POST /repos/{owner}/{repo}/issues/{index}/assets`.
* **`gitea-verify`** — asserts protection is still on and the agents are still
Write-not-Admin. Phase 2 is checked once; this checks it every day.
* ~~**`gitea-verify`**~~ — done, as `tools/gitea-protect --verify`: asserts every
field of the rule *independently* of what the apply path sends, and that both
agents are still Write-not-Admin. What is still missing is only the *every
day* part — nothing runs it on a timer yet.
* **wiki landing page** — bundles in flight and what each agent is on. There is
currently no view of what is happening except container logs.
@@ -222,9 +382,43 @@ Said plainly, because a runbook that hides its soft spots is worse than one that
does not:
* **that Gitea hides Approve from a PR's own author.** Widely true; Phase 2's
check tests it directly rather than trusting me.
* **the `--tools` filter names** (Phase 5) — an experiment, not a control.
check tests it directly rather than trusting me. What I no longer assume is
that it is *enough* — it says nothing about one agent approving the other,
which is what the approvals whitelist is for.
* **Gitea's Projects API**, which is why Phase 4 creates no board.
* **the exact Gitea version** — the API was unreachable from my sandbox on the
last three attempts. Every screen named here has been stable for many
releases, but if a menu is not where I say it is, that is why.
Settled since, rather than assumed:
* ~~the `--tools` filter names~~ — read out of the pinned release, and the
binary's `--help` run directly. Phase 5 lists the set.
* ~~the exact Gitea version~~ — **1.25.5**, confirmed independently from *both*
machines. `enable_merge_whitelist`, `enable_approvals_whitelist` and
`block_admin_merge_override` are all present in this instance's own API
schema, so the Phase 2 settings exist under those names on the Branches screen.
* ~~which machine can reach what~~ — the desktop reaches the Gitea API fine.
The token split in Phase 3 is about which session runs which script, and an
earlier draft that justified it as a network constraint was wrong.
### Wrong, not merely unverified
Kept separate, because "I had not checked" and "I asserted the opposite" are
different failures and only the second is worth a heading:
* **that requiring an approval closes the gate.** It does not. Merging ignores
the push whitelist entirely, and any Write collaborator is an official
reviewer — so the first version of Phase 2 would have let the two agents
approve each other and merge. Both whitelists exist because of it.
* **that the check could catch that.** It could not: with the approval
requirement unmet, Gitea offers *nobody* a merge button, so the original
steps 13 pass on a completely unprotected instance. Step 4 is the test.
* **that the `fabi` token should gain `write:repository`.** That scope is a push
credential.
* **that the desktop could not reach Gitea.** It can; `curl` was being refused
by a local permission prompt, which is not the same thing and was read as if
it were.
The first two were caught by the other agent. The pattern in all four is one
thing: **a property was inferred from something adjacent to it** — protection
from a settings page, reachability from a DNS record — instead of being tested
directly. That is the same failure the port's frozen-splash instruments made,
in a document about avoiding it.

View File

@@ -155,6 +155,40 @@ rule is written here so you know it, not so it depends on you.
A PR you cannot describe in a paragraph is an item that was too big. That is the
signal to split it, not to write a longer description.
### 🔴 A finding reaches `main` before the code that cites it
A citation that resolves only on a peer branch is **dead the moment it merges**.
Open the finding's PR first and make it a dependency of the code's.
This is not hypothetical and it is not small: **495 decoder commits and 366 port
commits sit off `main`**, so nearly anything either agent re-proposes will hit
it. `port/scripts/boot.gd` already cites two `docs/re/` pages that exist on
neither its own branch nor `main`.
## Checks that were kind once
Two rules that look unrelated and are the same failure.
**A check may only soften against a condition it can test.**
`gitea-protect --verify` printed ⚪ *"not a collaborator (yet)"* and continued
without failing — so the one instrument that checks Write-not-Admin could not
report that gate being **removed**. `check-citations` reported peer-branch
citations rather than failing them, because under the old branch topology that
was a state nobody could fix. Both were **correct and kind when written**, and
neither recorded that the kindness had a scope.
The test is mechanical, and you apply it to your own code:
> **Can this branch tell the difference between *not yet* and *no longer*?**
If it cannot, it does not get to be lenient. `--verify` could always ask whether
a collaborator exists, so the "yet" was never needed.
📌 **Nobody edits these into being wrong** — the world moves and the allowance
stays. That is why they survive review, and why the smell is worth naming:
*leniency with an expiry date nobody set.*
`share put <file> --note "…" --for port` records the sender, the time, **the
commit they were on**, and whether their tree was dirty. A capture with no
provenance is not evidence, it is a picture.

View File

@@ -67,10 +67,18 @@ One PR per item, closing its issue:
too big to be an item. The discipline stops depending on an agent's judgement.
🔴 **Agents must not merge their own pull requests.** The MCP's
`pull_request_write` includes `merge`, so this cannot be left to instruction —
it goes in **branch protection on `main`**, requiring review. Same principle that
fixed the build-jobs cap: policy belongs where the agent cannot reach it, not in
a document asking it not to.
`pull_request_write` includes `merge` and the tool cannot be split, so this
cannot be left to instruction — it goes in **branch protection on `main`**. Same
principle that fixed the build-jobs cap: policy belongs where the agent cannot
reach it, not in a document asking it not to.
⚠️ **"Requiring review" is not the rule that does it.** Gitea stops an author
approving their own pull request; it does not stop *the other agent* approving
it, and it never blocked merging in the first place — `Enable Push: off` blocks
pushes. The rule that holds is the pair of whitelists: **approvals whitelisted to
the human**, so an agent's approval does not count, and **merges whitelisted to
the human**, so an approved PR is still merged by a person. See
[`GITEA-SETUP.md`](GITEA-SETUP.md) Phase 2.
### 🔴 The wiki is NOT for the RE corpus

243
tools/gitea-protect Executable file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""Apply and verify branch protection on `main` -- Phase 2 of GITEA-SETUP.md.
tools/gitea-protect --dry-run print the exact rule it would send; no token
tools/gitea-protect create or update the rule (idempotent)
tools/gitea-protect --verify assert the live rule still holds; exit 1 if not
Six settings, of which two were missing from the first draft of the runbook and
both of those are the ones that close the gate. That is the shape of thing that
gets mis-clicked in a web form at 1am, so it goes through the API instead: what
was applied is reviewable in a diff, and `--verify` re-checks it every day
rather than once.
── Why each field is what it is ─────────────────────────────────────────────
Read out of Gitea's own models/git/protected_branch.go, not inferred:
EnableMergeWhitelist=false merging falls back on "whether the user has
write permission" -- and both agents have
Write. This is THE gate; without it every
other row is decoration.
EnableApprovalsWhitelist=false "anyone with write access is considered
official reviewer". Gitea refuses to let an
author approve their OWN pull request and does
nothing about sylph-decoder approving
sylph-port's, so without this the two agents
satisfy the human gate between themselves.
enable_push=false blocks PUSHES to main. It has no effect on
merging whatsoever, which is the assumption
that made the first version of this phase read
as protection while being none.
🔴 block_admin_merge_override stays FALSE, deliberately. Turning it on locks the
human out of their own work: approvals are whitelisted to `fabi`, Gitea will not
let `fabi` approve a `fabi` PR, so a human-authored PR could never reach one
approval and -- with the override blocked -- could never be merged at all. The
admin override is what keeps that door open, and it is not a hole in the agent
gate because the agents are Write, not Admin. That is what "Write, not Admin" in
Phase 1.2 is buying, and this is where it gets spent.
── Where the token comes from ───────────────────────────────────────────────
Branch protection is a REPOSITORY-scope endpoint, so `~/.sylph-gitea-api-token`
(write:issue, read:repository) cannot do it -- that token exists precisely so the
issue work needs no repository rights.
The credential that CAN is one you already have: `~/.sylph-git-credentials`, on
the agent box, scoped write:repository. Reusing it means this needs no new
credential and no second machine holding push rights, which is the whole reason
to run this here rather than on the Pi.
"""
import argparse, json, os, sys, urllib.error, urllib.parse, urllib.request
HOST = os.environ.get("SYLPH_GITEA_HOST", "git.mc02.dev")
REPO = os.environ.get("SYLPH_GITEA_REPO", "fabi/Sylpheed")
HUMAN = os.environ.get("SYLPH_GITEA_HUMAN", "fabi")
BRANCH = os.environ.get("SYLPH_GITEA_BRANCH", "main")
AGENTS = os.environ.get("SYLPH_GITEA_AGENTS", "sylph-decoder,sylph-port").split(",")
RULE = {
"rule_name": BRANCH,
"enable_push": False,
"required_approvals": 1,
"dismiss_stale_approvals": True,
"block_on_rejected_reviews": True,
"enable_merge_whitelist": True,
"merge_whitelist_usernames": [HUMAN],
"enable_approvals_whitelist": True,
"approvals_whitelist_username": [HUMAN],
"block_admin_merge_override": False, # see the module docstring
}
# What --verify asserts. Kept separate from RULE because a check that is written
# as "whatever we sent" cannot fail: it would re-derive the expectation from the
# thing under test. These are stated independently, on purpose.
EXPECTED = {
"enable_push": (lambda v: v is False, "pushes to the branch are blocked"),
"required_approvals": (lambda v: v >= 1, "at least one approval required"),
"dismiss_stale_approvals": (lambda v: v is True, "stale approvals dismissed"),
"block_on_rejected_reviews": (lambda v: v is True, "rejected reviews block the merge"),
"enable_merge_whitelist": (lambda v: v is True, "MERGE WHITELIST ON -- the gate"),
"merge_whitelist_usernames": (lambda v: v == [HUMAN], f"only {HUMAN} may merge"),
"enable_approvals_whitelist": (lambda v: v is True, "APPROVALS WHITELIST ON"),
"approvals_whitelist_username": (lambda v: v == [HUMAN], f"only {HUMAN}'s approval counts"),
}
def token():
"""The first credential that can plausibly do this, and a clear no otherwise."""
explicit = os.environ.get("SYLPH_GITEA_ADMIN_TOKEN")
if explicit and os.path.exists(explicit):
return open(explicit).read().strip(), explicit
cred = os.path.expanduser(os.environ.get("SYLPH_GIT_CREDENTIALS",
"~/.sylph-git-credentials"))
if os.path.exists(cred):
for line in open(cred):
line = line.strip()
if HOST in line and "@" in line:
parsed = urllib.parse.urlsplit(line)
if parsed.password:
return urllib.parse.unquote(parsed.password), cred
sys.exit(
f"gitea-protect: no repository-scoped credential found.\n\n"
f" Looked in $SYLPH_GITEA_ADMIN_TOKEN and {cred}.\n\n"
f" NOT ~/.sylph-gitea-api-token: that one is write:issue + read:repository\n"
f" by design, and every branch-protection endpoint refuses it. Run this on\n"
f" the machine that already holds the push credential rather than issuing a\n"
f" repository-scoped token to a second box.\n"
)
def api(method, path, tok, body=None):
url = f"https://{HOST}/api/v1/repos/{REPO}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"token {tok}",
"Content-Type": "application/json",
"Accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read().decode(errors="replace")
if e.code in (401, 403) and "scope" in raw:
sys.exit(f"🔴 that credential lacks repository scope:\n {raw.strip()}")
return e.code, raw
except urllib.error.URLError as e:
sys.exit(f"🔴 no response from {url} -- host or network: {e.reason}")
def apply_rule(tok):
status, existing = api("GET", f"/branch_protections/{BRANCH}", tok)
if status == 200:
status, out = api("PATCH", f"/branch_protections/{BRANCH}", tok,
{k: v for k, v in RULE.items() if k != "rule_name"})
verb = "updated"
elif status == 404:
status, out = api("POST", "/branch_protections", tok, RULE)
verb = "created"
else:
sys.exit(f"🔴 unexpected {status} reading the existing rule: {existing}")
if status not in (200, 201):
sys.exit(f"🔴 {verb.rstrip('d')} failed ({status}): {out}")
print(f" {verb} the protection rule on {BRANCH}")
return out
def verify(tok):
"""Assert, one line per property, and say which one failed rather than 'no'."""
ok = True
status, rule = api("GET", f"/branch_protections/{BRANCH}", tok)
if status == 404:
print(f"🔴 NO PROTECTION RULE on {BRANCH}. Anyone with Write can push to it.")
return False
if status != 200:
sys.exit(f"🔴 could not read the rule ({status}): {rule}")
for key, (pred, why) in EXPECTED.items():
got = rule.get(key)
good = pred(got)
ok &= good
print(f" {'✅' if good else '🔴'} {why:<42} {key}={got!r}")
# The other half of what a daily check is for: Phase 1.2's "Write, not
# Admin". An agent promoted to Admin could edit the rule above and then
# merge, so a green rule proves nothing on its own.
for agent in AGENTS:
status, perm = api("GET", f"/collaborators/{agent}/permission", tok)
# 🔴 A MISSING COLLABORATOR IS A FAILURE, not a blank. This branch used
# to print ⚪ and `continue`, leaving `ok` untouched -- so the one
# instrument that checks Phase 1.2 could not report Phase 1.2 being
# undone. An agent removed from the repository read as "nothing to say"
# rather than as a gate that is no longer there.
#
# It never actually fired: Gitea answers this endpoint with permission
# "read" for a non-collaborator rather than 404, so the case was caught
# by the role test below -- by luck, not by design. That is the same
# shape as a check that passes on an instance with no rule at all, and
# it is not worth keeping just because the luck has held.
if status == 404:
print(f" 🔴 {agent + ' is not a collaborator':<42} Phase 1.2 is undone")
ok = False
continue
if status != 200:
print(f" 🔴 {agent:<42} permission unreadable ({status})")
ok = False
continue
role = perm.get("permission")
good = role == "write"
ok &= good
print(f" {'✅' if good else '🔴'} {agent + ' is Write, not Admin':<42} permission={role!r}")
return ok
def main():
p = argparse.ArgumentParser(add_help=True, description=__doc__.split("\n")[0])
g = p.add_mutually_exclusive_group()
g.add_argument("--dry-run", action="store_true",
help="print the rule that would be sent; needs no credential")
g.add_argument("--verify", action="store_true",
help="check the live rule against what this file asserts")
a = p.parse_args()
print(f"repo https://{HOST}/{REPO}")
print(f"branch {BRANCH}\n")
if a.dry_run:
print(f"would PUT this rule (no credential read, nothing sent):\n")
print(json.dumps(RULE, indent=2))
print(f"\ndry run -- nothing was changed.")
return 0
tok, where = token()
print(f"credential from {where}\n")
if a.verify:
ok = verify(tok)
print()
print("protection holds." if ok else
"🔴 PROTECTION DOES NOT HOLD -- stop the agents until it does.")
return 0 if ok else 1
apply_rule(tok)
print()
ok = verify(tok)
print()
if ok:
print("Now run the check that a settings page cannot give you, from")
print("GITEA-SETUP.md Phase 2 -- especially step 4: approve the throwaway")
print("PR yourself, then confirm sylph-port STILL has no merge button.")
print("Steps 1-3 pass on an instance with no rule at all.")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -113,7 +113,12 @@ if [ "$DRY" = 1 ]; then
else
echo "labels and bundles are in place at https://$HOST/$REPO/issues"
echo
echo "Remaining, by hand in the web UI (the API for Projects lags it):"
echo " Projects -> New Project -> columns:"
echo " Proposed | Approved | In progress | Needs human | Done"
# No board, and this used to say the opposite. Gitea's project board does not
# follow labels, so it would be a SECOND copy of the state to hand-sync -- the
# exact failure that produced a 1,227-line BLOCKED.md. Labels are the truth and
# a saved issue filter gives the same view for nothing. Leaving the old
# "remaining, by hand: Projects -> New Project" line here would have had the
# tool instructing the reader to build the thing the doc argues against.
echo "No project board, deliberately -- labels are the truth. See"
echo "docs/agents/GITEA-SETUP.md Phase 4. Use a saved issue filter instead."
fi

View File

@@ -77,11 +77,28 @@ def main() -> int:
open(bad, "w").write("see `docs/port/this-file-does-not-exist-anywhere.md`\n")
good = os.path.join(tmp, "good.md")
open(good, "w").write("see `docs/port/PORT-MISSION.md`\n")
# The THIRD class, which `--for-merge` turns into a failure. It has to be
# told apart from both others: a peer citation is not dangling (the file
# exists) and does not resolve here (the reader still gets nothing), and
# a scanner that collapsed it into either would make the flag meaningless
# while still passing the two checks above.
peerfile = os.path.join(tmp, "peer.md")
open(peerfile, "w").write("see `docs/re/f5-a-press-snaps-the-plate.md`\n")
rp, pp, np_ = scan([peerfile])
_, _, nb = scan([bad])
r, _, ng = scan([good])
ok = len(nb) == 1 and len(ng) == 0 and r == 1
print("selftest: planted dangling caught=%s, real citation passed=%s -> %s"
% (len(nb) == 1, len(ng) == 0 and r == 1, "ok" if ok else "🔴 BROKEN"))
caught = len(nb) == 1
passed = len(ng) == 0 and r == 1
peer_ok = len(pp) == 1 and rp == 0 and len(np_) == 0
ok = caught and passed and peer_ok
print("selftest: planted dangling caught=%s, real citation passed=%s, "
"peer-branch classed separately=%s -> %s"
% (caught, passed, peer_ok, "ok" if ok else "🔴 BROKEN"))
if not peer_ok:
print(" 🔴 --for-merge cannot mean anything if the peer class is "
"not distinguished; got resolves=%d peer=%d nowhere=%d"
% (rp, len(pp), len(np_)))
return 0 if ok else 2
files = sorted(glob.glob("docs/port/*.md"))
@@ -89,9 +106,30 @@ def main() -> int:
total = resolves + len(peer) + len(nowhere)
print("citations of repo paths in docs/port/*.md: %d" % total)
print(" resolve here : %d" % resolves)
print(" on a peer branch, not merged: %d (reported, not failed)" % len(peer))
# 🔴 --for-merge TURNS THE PEER CLASS INTO A FAILURE.
#
# Reporting-not-failing was right when it was written: a peer-branch
# citation was "a state nobody in this container can fix", so failing on it
# would have been red for something unactionable. Under the pull-request
# workflow that stopped being true -- a PR into `main` is EXACTLY where it
# becomes fixable, by opening the finding's PR first and depending on it.
# The citation is dead the moment this merges, so the merge is the last
# place the leniency can still be withdrawn.
#
# Left as a flag rather than made unconditional, because both readings are
# still live: mid-work on a topic branch the peer class really is unfixable
# noise. The difference the old code could not express is WHERE the code is
# going, and that is a condition the caller can state.
merging = "--for-merge" in sys.argv
label = "🔴 FAILS (--for-merge)" if merging else "reported, not failed"
print(" on a peer branch, not merged: %d (%s)" % (len(peer), label))
for m, (src, ref) in sorted(peer.items()):
print(" %-52s %s <- %s" % (m, ref.split("/")[-1], os.path.basename(src)))
if peer and merging:
print("\n🔴 %d citation(s) resolve only on a peer branch." % len(peer))
print(" After this merges they resolve NOWHERE -- the reader gets a dead")
print(" path. Land the finding first and make it a dependency of this PR.")
return 1
if nowhere:
print(" 🔴 resolve NOWHERE : %d" % len(nowhere))
for m, src in sorted(nowhere.items()):