feat(mcp): publish an unpublished local commit on a registered issue worktree (Closes #812)

Entry point B of #812 is the state where an author's work has already advanced to a local commit: the worktree is registered, clean, on the issue branch, and carries the only copy of the implementation, but the branch has never been published. Two individually correct predicates close a cycle around it: exact-owner lease renewal refuses without an observable remote head, and every publication path is lock-derived under #618, so nothing can create that remote head without first holding the lock renewal would grant.

This adds the missing operation. gitea_publish_unpublished_issue_branch publishes an already-committed local head to its remote branch, so exact-owner renewal has the evidence its model requires. Publication is the whole of its authority: it renews, reclaims, rebinds, and clears nothing.

Why this is not a lock bypass: the operation can only publish a branch whose durable issue-lock record already names the caller as claimant. Ownership is read from the lock file, never asserted by the caller. Guard strictness is unchanged (AC15): a dirty tree, an untracked file the commit does not carry, an unregistered worktree, a non-issue or stable branch, a changed local HEAD, a remote head that is not an ancestor of the commit, a competing open PR for the same issue on another branch, and any declared-hash mismatch each fail closed. The refspec names the commit SHA explicitly and never forces.

Record separation (AC23): the durable issue-lock file and the control-plane workflow lease are distinct records. This reads the former as ownership evidence and writes neither.

Truthful process evidence (AC24): the recorded owner pid's liveness is never consulted or asserted. A regression pins the recorded pid to a live process, proves publication still succeeds, and proves expired-lock reclaim still refuses for that same pid.

Scope is AC20 only. AC21 cannot unblock on its own, so the two are separable and only the smaller one is implemented here.

Tests: 36 new cases against synthetic fixtures only, using a real git repository with a real local bare remote so publication and read-after-write verification are genuinely executed rather than mocked. AC17 is honoured and a regression asserts the protected worktree is never referenced.

Full suite: 11 failed, 4354 passed, 6 skipped, 533 subtests passed. The 11 failures are the documented pre-existing drift baseline at 9eb0f29, unchanged in count and identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-22 16:18:00 -05:00
co-authored by Claude Opus 4.8 (1M context) <[email protected]>
parent 9eb0f29cef
commit 99fda93bcc
7 changed files with 1487 additions and 0 deletions
+232
View File
@@ -2024,6 +2024,7 @@ import review_quarantine # noqa: E402 # #695 contaminated formal-review quaran
import mcp_daemon_guard # noqa: E402 # #695 native transport provenance
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import branch_publish # noqa: E402 # #812 AC20 unpublished-commit publication
import root_checkout_guard # noqa: E402
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
import stable_branch_push_guard # noqa: E402
@@ -9082,6 +9083,237 @@ def gitea_commit_files(
}
def _publication_block(reasons: list[str], **extra) -> dict:
"""Uniform fail-closed shape for publication refusals (#812 AC20)."""
payload = {
"success": False,
"performed": False,
"published": False,
"verified": False,
"outcome": branch_publish.REFUSED,
"reasons": reasons,
# State every record this operation left alone, so a refusal can never
# be misread as a lock mutation (#812 AC23).
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
}
payload.update(extra)
return payload
@mcp.tool()
def gitea_publish_unpublished_issue_branch(
issue_number: int,
branch_name: str,
worktree_path: str,
expected_head: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
git_remote_name: str | None = None,
expected_file_hashes: dict | None = None,
dry_run: bool = False,
) -> dict:
"""Publish an already-committed, unpublished issue branch (#812 AC20).
Creates the remote head for a branch whose work is *already* a local commit
on a registered, clean worktree, so exact-owner lease renewal
(``issue_lock_renewal``) has the published head its evidence model requires.
This is the one step of the entry point B deadlock that no existing tool can
perform: publication is otherwise lock-derived under #618, and the lock
itself is withheld until a remote head exists.
Not a lock bypass. The branch's **durable issue-lock record must already
name the caller as claimant** ownership is read from the lock file, never
asserted by the caller so this can only publish work the caller already
owns. It refuses a dirty or untracked-carrying worktree, an unregistered
worktree, a changed local HEAD, a remote head that is not an ancestor of the
commit, a competing open PR on another branch for the same issue, and any
declared-hash mismatch. It renews, reclaims, and clears nothing: the durable
issue-lock file and the control-plane workflow lease are both left untouched
(#812 AC23), and the recorded owner pid's liveness is never consulted or
asserted (#812 AC24).
Args:
issue_number: The issue whose recorded claim authorizes publication.
branch_name: Issue branch to publish, ``(fix|feat|docs|chore)/issue-N-``.
worktree_path: Registered worktree holding the commit.
expected_head: Full 40-character SHA of the commit to publish. Required:
publication names the exact commit, and a mismatch fails closed.
remote: Known instance 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
git_remote_name: Git remote to publish to; defaults to *remote*.
expected_file_hashes: Optional ``{path: sha256}`` verified against the
worktree before publication. Any mismatch or missing file refuses.
dry_run: Report the decision and evidence, mutate nothing.
Returns:
dict with 'success', 'performed', 'published', 'verified', 'outcome',
'remote_head_sha', 'reasons', and 'evidence'.
"""
task = "publish_unpublished_branch"
ok, block_reasons = role_session_router.check_author_mutation_after_reviewer_stop(
task
)
if not ok:
return _publication_block(block_reasons)
blocked = _namespace_mutation_block(task, remote=remote)
if blocked:
return blocked
blocked = _profile_permission_block(
task_capability_map.required_permission(task),
remote=remote, host=host, org=org, repo=repo,
org_explicit=org is not None,
repo_explicit=repo is not None,
)
if blocked:
return blocked
verify_preflight_purity(remote, task=task, org=org, repo=repo)
h, o, r = _resolve(remote, host, org, repo)
git_remote = (git_remote_name or remote or "").strip()
workspace = os.path.realpath(os.path.abspath((worktree_path or "").strip() or "."))
existing_lock = issue_lock_store.load_issue_lock(
remote=remote, org=o, repo=r, issue_number=int(issue_number)
)
git_state = issue_lock_worktree.read_worktree_git_state(workspace)
registered = author_mutation_worktree.path_in_git_worktree_list(
workspace, PROJECT_ROOT
)
remote_probe = branch_publish.read_remote_branch_head(
workspace, git_remote, branch_name
)
ancestry = None
probe_head = (remote_probe.get("remote_head_sha") or "").strip()
if probe_head and probe_head.lower() != (expected_head or "").strip().lower():
ancestry = branch_publish.read_is_ancestor(workspace, probe_head, expected_head)
# A PR on this very branch is this work's own PR, not a rival claim. Only an
# open PR for the same issue on a *different* branch is a competing claim.
competing: list = []
for pull in _list_open_pulls(h, o, r, _auth(h)):
ref = str((pull.get("head") or {}).get("ref") or "")
if not ref or ref == branch_name:
continue
if issue_lock_adoption.branch_carries_issue_marker(ref, int(issue_number)):
competing.append(pull.get("number"))
observed_hashes = None
if expected_file_hashes:
observed_hashes = branch_publish.hash_worktree_files(
workspace, list(expected_file_hashes.keys())
)
claimant = _work_lease_claimant(h)
assessment = branch_publish.assess_unpublished_commit_publication(
existing_lock,
issue_number=int(issue_number),
branch_name=branch_name,
worktree_path=workspace,
expected_head=expected_head,
remote=remote,
org=o,
repo=r,
identity=claimant.get("username"),
profile=claimant.get("profile"),
worktree_state=git_state,
worktree_registered=registered,
remote_probe=remote_probe,
ancestry=ancestry,
competing_open_prs=competing,
expected_file_hashes=expected_file_hashes,
observed_file_hashes=observed_hashes,
)
if assessment["outcome"] == branch_publish.REFUSED:
return _publication_block(
assessment["reasons"], evidence=assessment["evidence"]
)
if dry_run:
return {
"success": True,
"performed": False,
"published": False,
"verified": False,
"dry_run": True,
"outcome": assessment["outcome"],
"would_publish": assessment["publish_sanctioned"],
"remote_head_sha": assessment["evidence"].get("remote_head_sha"),
"reasons": [],
"evidence": assessment["evidence"],
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
}
performed = False
if assessment["publish_sanctioned"]:
with _audited(
task, host=h, remote=remote, org=o, repo=r,
target_branch=branch_name,
request_metadata={
"issue_number": int(issue_number),
"expected_head": expected_head,
"git_remote": git_remote,
},
):
push = branch_publish.publish_commit_to_remote_branch(
worktree_path=workspace,
remote_name=git_remote,
branch_name=branch_name,
expected_head=expected_head,
)
if not push.get("success"):
return _publication_block(
push.get("reasons") or ["publication failed"],
evidence=assessment["evidence"],
stderr=push.get("stderr"),
)
performed = True
verification = branch_publish.verify_published_head(
worktree_path=workspace,
remote_name=git_remote,
branch_name=branch_name,
expected_head=expected_head,
)
if not verification.get("verified"):
return _publication_block(
verification.get("reasons") or ["read-after-write verification failed"],
evidence=assessment["evidence"],
performed=performed,
published=performed,
remote_head_sha=verification.get("remote_head_sha"),
)
return {
"success": True,
"performed": performed,
"published": True,
"verified": True,
"outcome": assessment["outcome"],
"remote_head_sha": verification.get("remote_head_sha"),
"reasons": [],
"evidence": assessment["evidence"],
# Publication is the whole of this operation's authority (#812 AC23).
"issue_lock_record_mutated": False,
"workflow_lease_touched": False,
"exact_next_action": (
f"Remote head for '{branch_name}' is now observable. Call "
f"gitea_lock_issue(issue_number={int(issue_number)}, "
f"branch_name='{branch_name}', worktree_path='{workspace}') to renew "
"the exact-owner lease, then gitea_create_pr."
),
}
# Merge methods supported by the Gitea merge API.
_MERGE_METHODS = ("merge", "squash", "rebase")