From 097a6c56d3d81de16f6cfb684f76ac3a8d994ed1 Mon Sep 17 00:00:00 2001 From: datacas <4690232+datacas@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:35:05 +0100 Subject: [PATCH] feat(runtime): audit publication invariants after every publishing stage (#261) After every implement, review, resolve and rereview dispatch, whatever the stage reported, the runtime compares the audited state on origin with its state before the stage: the cycle's change requests stay open, the cycle branch only moves fast-forward, and the default and protected branches neither move nor disappear. A breach stops the cycle with publication_violation and before/after evidence; an unreadable state stops it with publication_unverified. Hosts other than GitHub keep their refs audited and report the rest as unverified. Telemetry schema 24. Closes #261 --- CHANGELOG.md | 9 + docs/instrumentation.md | 18 +- docs/review-cycle.md | 1 + docs/role-workspace-policy.md | 65 ++++- scripts/publication_audit.py | 364 ++++++++++++++++++++++++++ scripts/run_cycle.py | 85 +++++- scripts/runtime.manifest | 1 + scripts/telemetry.py | 8 +- scripts/windows-test-shards.json | 2 + tests/test_publication_audit.py | 434 +++++++++++++++++++++++++++++++ tests/test_work_item_outcomes.py | 2 +- 11 files changed, 980 insertions(+), 9 deletions(-) create mode 100644 scripts/publication_audit.py create mode 100644 tests/test_publication_audit.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e78481d..3035c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ All notable changes to this project are documented here. This project follows ### Added +- **Publication audit (#261).** After every publishing stage, whatever it + reported, `run_cycle.py` compares the cycle branch, the default branch and + the protected branches on `origin`, and the cycle's change requests, with + their state before the stage. A merged or closed change request, a + non-fast-forward move or deletion of the cycle branch, or any move or + deletion of the default or a protected branch stops the cycle with + `publication_violation` and the before/after evidence; an unreadable state + stops it with `publication_unverified`. Code hosts other than GitHub have + their refs audited and the rest reported `unverified`. Telemetry schema 24. - **Manual rereview (#346).** `manual_rereview.py record` attaches a rereview that ran outside the runtime to its pull request with a schema-2 receipt marked `provenance: manual`, the authenticated operator, the confirmed head diff --git a/docs/instrumentation.md b/docs/instrumentation.md index 7a012d4..e5c92d3 100644 --- a/docs/instrumentation.md +++ b/docs/instrumentation.md @@ -643,7 +643,7 @@ Each outcome has one source row, listed in `telemetry.OUTCOME_FIELDS`: | `cycle` | `boundary_verified` | the latest test report recorded it (schema 9) | | `cycle` | `fallback_stages`, `contract_violations` | always: every dispatch of the run went through the recorder | | `cycle` | `status`, `iterations` | always | -| `cycle` | `stop_reason` | the run closed through `run_cycle.py` (schema 7); `needs_refinement` and `readiness_unconfirmed` from schema 11; `interrupted` from schema 12; `ci_timeout`, `ci_unreadable`, `ci_not_passing`, and `head_moved` from schema 19; `manual_rereview_rejected` from schema 22; `worktree_dirty` and `worktree_diverged` when safe synchronization to an open pull request head is impossible | +| `cycle` | `stop_reason` | the run closed through `run_cycle.py` (schema 7); `needs_refinement` and `readiness_unconfirmed` from schema 11; `interrupted` from schema 12; `ci_timeout`, `ci_unreadable`, `ci_not_passing`, and `head_moved` from schema 19; `manual_rereview_rejected` from schema 22; `publication_violation` and `publication_unverified` from schema 24; `worktree_dirty` and `worktree_diverged` when safe synchronization to an open pull request head is impossible | | `cycle` | `issue_review` | the cycle started at `implement` and was not continued: `off`, `skipped` (declared trivial and not security-sensitive), or `dispatched` (schema 11) | | `cycle` | `continued` | the cycle continued an interrupted implementation with `--continue`; always `true` when present (schema 12) | | `cycle` | `repeated_findings` | the ladder was evaluated at least once; the last value, so a survival the final rereview found is kept when no dispatch follows it (schema 7) | @@ -707,9 +707,12 @@ than a completion, or only started work that finishes elsewhere), needs a sensitive sandbox grant nobody answered; see *Sensitive sandbox grants* in `routing.md`), `manual_rereview_rejected` (schema 22: the latest receipt is a manual rereview a resume cannot close on; see *Manual rereview* in -`review-cycle.md`), `worktree_dirty`, or `worktree_diverged`. The last two stop -before changing files when a dirty tree or non-fast-forward prevents syncing an -open pull request head. A closing row from an earlier version has none, and +`review-cycle.md`), `publication_violation` (schema 24: a publication invariant +failed during a publishing stage, whoever caused it), `publication_unverified` +(schema 24: the audited refs or change-request state could not be read; see +*Publication audit* in `role-workspace-policy.md`), `worktree_dirty`, or +`worktree_diverged`. The last two stop before changing files when a dirty tree +or non-fast-forward prevents syncing an open pull request head. A closing row from an earlier version has none, and `cc-stats` reads it as unknown. A stage row's `reroute_reason` may then be `permission_required` or `permission_answered`. @@ -767,6 +770,13 @@ its matching row. The additive table does not rewrite existing telemetry, and a code rollback leaves unused rows. The `quota_resume_reason` cycle payload field records when a continuation routes around a persisted exhaustion. +Schema 24 adds the stop reasons `publication_violation` and +`publication_unverified` of the [publication audit](role-workspace-policy.md#publication-audit). +They are payload-only. The audit's evidence is not stored in the telemetry +rows. The cycle journal holds one `publication_audit` result per publishing +stage and one `failure` row per breach, with the ref and its before and after +SHA. The full evidence is in the cycle report. + The routing rules' choice is the `profile` on each `dispatch` row. A later selector's suggestion can be compared with it, and with the outcome, by `(cycle_id, stage_seq)`, without that selector being installed while the run is diff --git a/docs/review-cycle.md b/docs/review-cycle.md index 67356aa..2ee0f57 100644 --- a/docs/review-cycle.md +++ b/docs/review-cycle.md @@ -354,6 +354,7 @@ The loop stops early with `HUMAN_INTERVENTION`, before another resolution is dis | No progress | a rereview reports the same `head_sha` and the same open finding set as the review before it | `no_progress` | | Repeated findings | one `REV-xxx` ID survived a claimed fix twice | `repeated_findings` | | Manual rereview not accepted | a resume's latest receipt is a [manual rereview](#manual-rereview) it cannot close on | `manual_rereview_rejected` | +| Publication invariant failed | during any publishing stage, a change request was merged or closed, the cycle branch moved non-fast-forward or was deleted, or the default or a protected branch moved ([Publication audit](role-workspace-policy.md#publication-audit)) | `publication_violation` | Before the loop, an [issue review](#issue-review-before-implementation) can stop a new cycle as `needs_refinement` or `readiness_unconfirmed` without implementing anything. diff --git a/docs/role-workspace-policy.md b/docs/role-workspace-policy.md index 5644e8c..1320aed 100644 --- a/docs/role-workspace-policy.md +++ b/docs/role-workspace-policy.md @@ -102,8 +102,9 @@ equal to the expected commit. Before a success comment or receipt, the stage also reads the PR using `gh` and confirms that head. The runtime independently compares the reported SHA, local HEAD and current PR head before recording a successful functional verdict. A failure/progress comment must state that the -push failed and cannot count as a successful resolution. The general invariant -audit for merges, force pushes and unrelated refs remains separate (#261). +push failed and cannot count as a successful resolution. The invariant audit +for merges, force pushes and deleted refs is described in +[Publication audit](#publication-audit) below. The completion probe normalizes supported GitHub PR references (a number, `#number`, `owner/repo#number`, or a GitHub `/pull/number` URL) to the numeric identifier before checking the remote head. @@ -115,6 +116,66 @@ same upload after a failure; it creates no new commit. On resume, reuse an existing success publication rather than duplicating it. Bitbucket and Orca transport recovery are not verified by this GitHub native path. +## Publication audit + +The cycle never merges, force-pushes or deletes refs. A stage's command table +(#142) narrows what it can run, but code that its verification executes and +non-Bash tools that its host pre-approves can still reach the code host. So +`run_cycle.py` checks the result after every publishing stage (`implement`, +`review`, `resolve`, `rereview`), on every executor, whatever status the stage +reported (`scripts/publication_audit.py`): + +1. **Change requests.** Every change request of the cycle that was open before + the stage is still open, and any change request the stage created is open. + A merge or a close is a breach. The audit reads the change request that the + stage was given, or for `implement` the one it reports, and every change + request whose head is the cycle branch. +2. **Cycle branch.** Its remote head did not move, or moved fast-forward from + the head recorded before the stage. A non-fast-forward move or a deletion + is a breach. `implement` may create the branch when it was absent. A commit + the checkout does not have is fetched into `FETCH_HEAD` only, so the audit + leaves no ref behind. +3. **Default and protected branches.** They did not move, and none was deleted. + Any move is a breach, even a fast-forward or a legitimate push by someone + else. + +The audited refs are the cycle branch (the change request's head branch when +the runtime knows it, otherwise the checkout's branch), the default branch +(`code_cycle.repository.default_branch` when configured, otherwise the branch +`origin`'s `HEAD` names), and the branches the code host reports as protected. +A remote that has no branch yet has no default branch to audit; that part is +reported `unverified`. They are read once +before the stage, on `origin` only, and the same set is compared after it. A +push to any other branch never fails the check (#197). + +The runtime cannot tell who moved a ref during a stage. A breach stops the +cycle with `publication_violation` whoever caused it, and the stop line says +"changed outside the stage?" instead of blaming the stage. `publication_violation` +means that an invariant failed, not that the stage caused it. The audit runs +before the stage's report is read, so a breach wins over a `BLOCKED` or any +other `stage_not_completed`. No later stage is dispatched. The evidence goes to +the cycle report (`CycleReport.publication_audit`) and the cycle journal. It +holds the before and after SHA of each audited ref, the before and after state +of each change request, and for each breach its kind (`change_request_merged`, +`change_request_closed`, `non_fast_forward`, `protected_ref_moved`, +`ref_deleted`) with up to 20 commits added and removed when they are available. + +A state that cannot be read is never read as empty. If `origin`'s refs, its +default branch, the protected branches or a change request cannot be read, the +cycle stops with `publication_unverified`. Before the stage, nothing is +dispatched. After it, the stage's report is not accepted. + +On GitHub the change requests and protected branches are read through `gh api`. +Other code hosts, Bitbucket included, have no reader until #238. For those, the +cycle branch and the default branch are still audited through Git, and the +change-request state and protected branches are reported `unverified` in the +cycle report, never as passed. A `--local-only` run publishes nothing and is +not audited. + +Reconciling a stage that published a verified result but reported `BLOCKED` is +#364. Until it exists, a `BLOCKED` result is never turned into a success, even +when the SHAs match. + --- [← Optional workspace tools](workspace-tools.md) · [↑ Documentation index](README.md) · [Host permissions →](host-permissions.md) diff --git a/scripts/publication_audit.py b/scripts/publication_audit.py new file mode 100644 index 0000000..54f1a5f --- /dev/null +++ b/scripts/publication_audit.py @@ -0,0 +1,364 @@ +"""What a publishing stage left on the code host, checked after it (#261). + +The cycle never merges, never force-pushes and never deletes refs. A stage's +command table (#142) narrows what it can run, but code a verification executes +and tools its host pre-approves can still reach the code host. So after every +publishing stage the runtime compares the state the invariants name with the +state it read before the stage: + +1. every change request of the cycle that was open is still open, and any the + stage created is open; none was merged or closed; +2. the cycle branch did not move, or moved fast-forward from its old head; +3. the default branch and the branches the code host reports as protected did + not move, and none of the audited refs was deleted. + +Only the refs the invariants name are read, on `origin` only, so a push to an +unrelated branch elsewhere never fails the check (#197). The runtime cannot +tell who moved a ref during a stage, so a breach is reported as an invariant +that failed, "changed outside the stage?", never as proof that the stage did +it. A state that cannot be read is never read as "nothing there": it raises +`AuditUnreadable`, and the cycle stops instead of continuing with incomplete +coverage. A code host this module cannot read (anything but GitHub until #238) +is reported `unverified` for the parts it could not check, never as passed. +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import dataclass, field +from typing import Callable, Protocol +from urllib.parse import quote + +#: The only remote the audit reads. +AUDITED_REMOTE = "origin" +#: At most this many commits per direction are kept as evidence of a move. +COMMIT_LIMIT = 20 + +PASSED = "passed" +VIOLATED = "violated" +UNVERIFIED = "unverified" + +#: Breach kinds, closed vocabulary. +MERGED = "change_request_merged" +CLOSED = "change_request_closed" +NON_FAST_FORWARD = "non_fast_forward" +MOVED = "protected_ref_moved" +DELETED = "ref_deleted" + +#: Parts of the audit a code host without a reader leaves unchecked. +CHANGE_REQUEST_STATE = "change_request_state" +PROTECTED_BRANCHES = "protected_branches" +#: A remote with no branch yet and no configured default branch. +DEFAULT_BRANCH = "default_branch" + +_OPEN = "OPEN" + + +class AuditUnreadable(RuntimeError): + """The audited state could not be read; the coverage would be incomplete.""" + + +class HostReader(Protocol): + """How the audit reads a code host; injected so tests need no network.""" + + def protected_branches(self, repo: str) -> tuple[str, ...]: + """Every branch the code host reports as protected, possibly none.""" + + def change_requests(self, repo: str, branch: str | None, + change_request_id: str | None) -> dict[str, dict]: + """`{number: {"state": OPEN|CLOSED|MERGED, "head_ref": name}}` of the + change requests whose head is `branch`, and of `change_request_id`.""" + + +class GitHubHostReader: + """`HostReader` for GitHub, through `gh api`.""" + + def __init__(self, cwd: str | None = None, *, run: Callable = subprocess.run, + timeout: float = 30) -> None: + self.cwd = cwd + self.run = run + self.timeout = timeout + + def _api(self, path: str, *, paginate: bool = False): + command = ["gh", "api"] + if paginate: + command += ["--paginate", "--slurp"] + command.append(path) + try: + done = self.run(command, cwd=self.cwd, capture_output=True, text=True, + timeout=self.timeout, check=False) + except (OSError, subprocess.SubprocessError) as error: + raise AuditUnreadable("GitHub tooling unavailable or timed out") from error + if done.returncode: + raise AuditUnreadable(f"gh api {path.split('?')[0]} exited {done.returncode}") + try: + return json.loads(done.stdout) + except ValueError as error: + raise AuditUnreadable("GitHub returned invalid JSON") from error + + @staticmethod + def _items(pages) -> list[dict]: + if not isinstance(pages, list) or any(not isinstance(page, list) for page in pages): + raise AuditUnreadable("GitHub returned pages in an unknown shape") + items = [item for page in pages for item in page] + if any(not isinstance(item, dict) for item in items): + raise AuditUnreadable("GitHub returned an entry in an unknown shape") + return items + + def protected_branches(self, repo: str) -> tuple[str, ...]: + names = [] + for branch in self._items(self._api( + f"repos/{repo}/branches?protected=true&per_page=100", paginate=True)): + name = branch.get("name") + if not isinstance(name, str) or not name: + raise AuditUnreadable("a protected branch names no branch") + names.append(name) + return tuple(dict.fromkeys(names)) + + @staticmethod + def _pull(pull: dict) -> tuple[str, dict]: + number, state = pull.get("number"), pull.get("state") + head = pull.get("head") + head_ref = head.get("ref") if isinstance(head, dict) else None + if (isinstance(number, bool) or not isinstance(number, int) + or not isinstance(state, str) or not isinstance(head_ref, str)): + raise AuditUnreadable("a pull request came back in an unknown shape") + merged = pull.get("merged") is True or bool(pull.get("merged_at")) + return str(number), {"state": "MERGED" if merged else state.upper(), + "head_ref": head_ref} + + def change_requests(self, repo: str, branch: str | None, + change_request_id: str | None) -> dict[str, dict]: + found: dict[str, dict] = {} + if branch: + owner = repo.partition("/")[0] + head = quote(f"{owner}:{branch}", safe="") + for pull in self._items(self._api( + f"repos/{repo}/pulls?state=all&head={head}&per_page=100", + paginate=True)): + number, state = self._pull(pull) + found[number] = state + if change_request_id: + pull = self._api(f"repos/{repo}/pulls/{change_request_id}") + if not isinstance(pull, dict): + raise AuditUnreadable("the pull request came back in an unknown shape") + number, state = self._pull(pull) + found[number] = state + return found + + +@dataclass(frozen=True) +class Snapshot: + """The audited state, read once before a stage.""" + + cycle_branch: str | None + default_branch: str | None + protected: tuple[str, ...] + #: Branch name to remote head SHA, None when the branch was absent. + refs: dict[str, str | None] + change_requests: dict[str, dict] + unverified: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Breach: + kind: str + ref: str + before: str | None = None + after: str | None = None + commits_added: tuple[str, ...] = () + commits_removed: tuple[str, ...] = () + + def evidence(self) -> dict: + item = {"kind": self.kind, "ref": self.ref, + "before": self.before, "after": self.after} + if self.commits_added: + item["commits_added"] = list(self.commits_added) + if self.commits_removed: + item["commits_removed"] = list(self.commits_removed) + return item + + def explain(self) -> str: + if self.kind in {MERGED, CLOSED}: + return f"{self.ref} is {self.after} (was {self.before or 'absent'})" + if self.kind == DELETED: + return f"{self.ref} was deleted (was {self.before})" + what = "moved non-fast-forward" if self.kind == NON_FAST_FORWARD else "moved" + return f"{self.ref} {what}: {self.before or 'absent'} -> {self.after}" + + +@dataclass(frozen=True) +class AuditResult: + status: str + before: Snapshot + after_refs: dict[str, str | None] + after_change_requests: dict[str, dict] + breaches: tuple[Breach, ...] = () + unverified: tuple[str, ...] = () + + def evidence(self) -> dict: + """JSON-safe: SHAs, ref names, states and breach kinds, never prose.""" + refs = {name: {"before": sha, "after": self.after_refs.get(name)} + for name, sha in sorted(self.before.refs.items())} + requests = {number: {"before": (self.before.change_requests.get(number) or {}).get("state"), + "after": state.get("state")} + for number, state in sorted(self.after_change_requests.items())} + evidence = {"status": self.status, "remote": AUDITED_REMOTE, + "cycle_branch": self.before.cycle_branch, + "default_branch": self.before.default_branch, + "protected": list(self.before.protected), + "refs": refs, "change_requests": requests, + "breaches": [breach.evidence() for breach in self.breaches]} + if self.unverified: + evidence["unverified"] = list(self.unverified) + return evidence + + def explain(self, role: str) -> str: + details = "; ".join(breach.explain() for breach in self.breaches) + return (f"publication invariant failed during the {role} stage (changed outside " + f"the stage?): {details}") + + +def _remote_heads(git: Callable[..., str]) -> dict[str, str]: + heads = {} + for line in git("ls-remote", "--heads", AUDITED_REMOTE).splitlines(): + sha, _, ref = line.partition("\t") + if ref.startswith("refs/heads/") and sha: + heads[ref[len("refs/heads/"):]] = sha + return heads + + +def _default_branch(git: Callable[..., str], heads: dict[str, str]) -> str | None: + """`origin`'s default branch; None only for a remote that has no branch yet.""" + for line in git("ls-remote", "--symref", AUDITED_REMOTE, "HEAD").splitlines(): + target, _, name = line.partition("\t") + if target.startswith("ref: refs/heads/") and name == "HEAD": + return target[len("ref: refs/heads/"):] + if not heads: + return None + raise AuditUnreadable(f"{AUDITED_REMOTE} names no default branch") + + +class PublicationAuditor: + """Read the audited state before a publishing stage and verify it after.""" + + def __init__(self, cwd: str, repo: str, host: HostReader | None, *, + default_branch: str | None = None, + run: Callable = subprocess.run, timeout: float = 60) -> None: + """`default_branch` is the configured one; otherwise `origin`'s HEAD.""" + self.cwd = cwd + self.repo = repo + self.host = host + self.default_branch = default_branch + self.run = run + self.timeout = timeout + + def _git(self, *args: str, check: bool = True) -> str: + try: + done = self.run(["git", "-C", self.cwd, *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=self.timeout, + check=False) + except (OSError, subprocess.SubprocessError) as error: + raise AuditUnreadable(f"git {args[0]} could not run") from error + if check and done.returncode: + raise AuditUnreadable(f"git {args[0]} exited {done.returncode}") + return done.stdout + + def _status(self, *args: str) -> int: + try: + return self.run(["git", "-C", self.cwd, *args], capture_output=True, text=True, + timeout=self.timeout, check=False).returncode + except (OSError, subprocess.SubprocessError) as error: + raise AuditUnreadable(f"git {args[0]} could not run") from error + + def _change_requests(self, branch: str | None, request: str | None) -> dict[str, dict]: + if self.host is None: + return {} + return self.host.change_requests(self.repo, branch, request) + + def capture(self, *, checkout_branch: str | None, + change_request_id: str | None = None) -> Snapshot: + """The audited state now. Raises `AuditUnreadable` on any doubt.""" + heads = _remote_heads(self._git) + default = self.default_branch or _default_branch(self._git, heads) + unverified: list[str] = [] if default else [DEFAULT_BRANCH] + if self.host is None: + protected: tuple[str, ...] = () + unverified += [CHANGE_REQUEST_STATE, PROTECTED_BRANCHES] + else: + protected = tuple(self.host.protected_branches(self.repo)) + requests = self._change_requests(checkout_branch, change_request_id) + branch = checkout_branch + if change_request_id and change_request_id in requests: + # The change request's own head, when the checkout names it otherwise. + branch = requests[change_request_id].get("head_ref") or branch + if branch != checkout_branch: + requests.update(self._change_requests(branch, None)) + names = dict.fromkeys(name for name in (branch, default, *protected) if name) + return Snapshot(cycle_branch=branch, default_branch=default, protected=protected, + refs={name: heads.get(name) for name in names}, + change_requests=requests, unverified=tuple(unverified)) + + def verify(self, before: Snapshot, *, + change_request_id: str | None = None) -> AuditResult: + """Compare the same audited state with `before`.""" + heads = _remote_heads(self._git) + after_refs = {name: heads.get(name) for name in before.refs} + breaches = [] + for name, old in before.refs.items(): + new = after_refs[name] + if new == old: + continue + ref = f"refs/heads/{name}" + if new is None: + breaches.append(Breach(DELETED, ref, old, None)) + continue + strict = name == before.default_branch or name in before.protected + if old is None: + if strict: + breaches.append(Breach(MOVED, ref, None, new)) + continue # The cycle branch was created. + # Any move of the default or a protected branch is a breach, even + # a fast-forward; the cycle branch may only move forward. + if strict or not self._is_ancestor(old, new, name): + breaches.append(Breach( + MOVED if strict else NON_FAST_FORWARD, ref, old, new, + commits_added=self._commits(old, new), + commits_removed=self._commits(new, old))) + after_requests = self._change_requests(before.cycle_branch, change_request_id) + for number in before.change_requests: + if number not in after_requests and self.host is not None: + after_requests.update(self._change_requests(None, number)) + for number, state in sorted(after_requests.items()): + was = (before.change_requests.get(number) or {}).get("state") + now = state.get("state") + if now != _OPEN and was in (None, _OPEN): + breaches.append(Breach(MERGED if now == "MERGED" else CLOSED, + f"change_request:{number}", was, now)) + status = VIOLATED if breaches else UNVERIFIED if before.unverified else PASSED + return AuditResult(status=status, before=before, after_refs=after_refs, + after_change_requests=after_requests, + breaches=tuple(breaches), unverified=before.unverified) + + def _has(self, sha: str) -> bool: + return self._status("cat-file", "-e", f"{sha}^{{commit}}") == 0 + + def _is_ancestor(self, old: str, new: str, branch: str) -> bool: + if not self._has(new): + # Into FETCH_HEAD only: the audit leaves no ref behind. + self._status("fetch", "--no-tags", "--quiet", AUDITED_REMOTE, f"refs/heads/{branch}") + if not (self._has(old) and self._has(new)): + raise AuditUnreadable( + f"the move of refs/heads/{branch} cannot be compared: a commit is not available") + code = self._status("merge-base", "--is-ancestor", old, new) + if code not in (0, 1): + raise AuditUnreadable(f"the move of refs/heads/{branch} cannot be compared") + return code == 0 + + def _commits(self, exclude: str, include: str) -> tuple[str, ...]: + """The commits in `include` and not in `exclude`, when available.""" + if not (self._has(include) and self._has(exclude)): + return () + return tuple(self._git("rev-list", f"--max-count={COMMIT_LIMIT}", include, + f"^{exclude}", check=False).split()) diff --git a/scripts/run_cycle.py b/scripts/run_cycle.py index 5ce630b..5881036 100644 --- a/scripts/run_cycle.py +++ b/scripts/run_cycle.py @@ -97,6 +97,9 @@ LockHeld, RemoteMark, ) +from publication_audit import ( + AuditUnreadable, GitHubHostReader, PublicationAuditor, VIOLATED as AUDIT_VIOLATED, +) from cycle_status import ( CycleStatusWriter, _configure_stdout, _duration, _visible_revision, _write_cursor, status_directory, @@ -381,6 +384,9 @@ def completes(role: str, status: str | None, ci_policy: str = DEFAULT_CI_POLICY) #: agent's own `git diff` may pass through one that shortens it. DIFF_ROLES = frozenset({"review", "resolve", "rereview"}) +#: The stages that may write to the code host, each audited after it (#261). +PUBLISHING_ROLES = frozenset({"implement", "review", "resolve", "rereview"}) + #: What a diff stage is told when no base resolved: no file, and no pretence. NO_DIFF_FILE = ( "Complete diff: the runtime wrote no diff file for this stage, because no " @@ -893,6 +899,9 @@ class CycleReport: journal_explanation: dict[str, object] | None = None #: `manual` when the cycle closed on a manual rereview (#346), else None. review_provenance: str | None = None + #: The last publication audit's evidence (#261): refs and change-request + #: states before and after the stage, and every breach. SHAs and names only. + publication_audit: dict | None = None @property def approved(self) -> bool: @@ -2440,6 +2449,7 @@ def run_cycle( ci_sleep: Callable[[float], None] | None = None, repair_approval: str | None = None, published_change_request: Callable[[], str | None] | None = None, + publication_auditor: PublicationAuditor | None = None, ) -> CycleReport: """[issue_review ->] implement -> review -> (resolve -> rereview)*, recorded. @@ -2489,6 +2499,11 @@ def run_cycle( cycle reads the open pull request of the checkout's branch. The continuation is told to reuse it, and a result naming another change request stops before review instead of reviewing a duplicate. + + With `publication_auditor` (#261), every publishing stage is bracketed by a + read of the code host's audited state, and the cycle stops with + `publication_violation` when an invariant failed, whatever the stage + reported, or `publication_unverified` when that state cannot be read. """ validate_start(start_from, change_request_id, local_only=local_only, continue_work=continue_work) @@ -3111,6 +3126,47 @@ def unfinished(outcome: StageOutcome, reported: Reported, return stop(_elsewhere(outcome), reported=reported) return None + def audit_publication(role: str, before, reported: Reported, + request: str | None) -> CycleReport | None: + """Stop when a publication invariant failed during `role` (#261). + + The change request the stage was given, or for `implement` the one it + reports, is read as well as those whose head is the cycle branch. A + breach names what moved, never who moved it. + """ + number = _pull_number(request) + if number is None and role == "implement": + number = reported.telemetry_pull_request_id("github") + try: + result = publication_auditor.verify(before, change_request_id=number) + except AuditUnreadable as error: + report.publication_audit = {"status": "unreadable", "role": role} + return stop(f"the publication audit could not read the code host after the " + f"{role} stage: {error}", "BLOCKED", reported=reported, + stop_reason="publication_unverified") + evidence = {"role": role, **result.evidence()} + report.publication_audit = evidence + journal.record("stage_result", role=role, stage_seq=recorder.stage_seq, + component="publication_audit", status=result.status, + correlation={"cycle_branch": result.before.cycle_branch, + "default_branch": result.before.default_branch, + "breaches": len(result.breaches)}) + for breach in result.breaches: + journal.record("failure", role=role, stage_seq=recorder.stage_seq, + component="publication_audit", status=breach.kind, + reason=breach.explain(), + correlation={"ref": breach.ref, "before": breach.before, + "after": breach.after}) + if result.unverified: + report.note(f"publication audit after {role}: unverified " + f"({', '.join(result.unverified)}); the rest was checked") + if result.status != AUDIT_VIOLATED: + return None + for breach in result.breaches: + report.note(f" publication breach {json.dumps(breach.evidence(), sort_keys=True)}") + return stop(result.explain(role), reported=reported, + stop_reason="publication_violation") + def review_issue() -> CycleReport | None: """The readiness gate: only a confirmed READY lets `implement` run. @@ -3205,6 +3261,17 @@ def advance(role: str, instruction: str = "", *, component="runtime", status="worktree_synchronized", reason="clean worktree fast-forwarded to the open pull request head", correlation={"old_head_sha": old_head, "new_head_sha": new_head}) + audited = None + if publication_auditor is not None and role in PUBLISHING_ROLES: + try: + audited = publication_auditor.capture( + checkout_branch=_git_line(cwd, ["branch", "--show-current"], 30), + change_request_id=_pull_number(change_request_id)) + except AuditUnreadable as error: + return Reported(), stop( + f"the publication audit could not read the code host before the {role} " + f"stage, so nothing was dispatched: {error}", "BLOCKED", + stop_reason="publication_unverified") outcome, reported, tampered, findings = run( role, instruction, change_request_id=change_request_id, resolve_target=resolve_target, @@ -3212,6 +3279,12 @@ def advance(role: str, instruction: str = "", *, resolve_escalated=resolve_escalated, escalation_finding_ids=escalation_finding_ids, publishes=recover) + if audited is not None: + # Before anything the stage reported is read, whatever it reported: + # a breach is never a `stage_not_completed` (#261, #364). + stopped = audit_publication(role, audited, reported, change_request_id) + if stopped is not None: + return reported, stopped if role == "resolve" and outcome.decision is not None: previous_resolve_target = outcome.decision.target previous_resolve_used_fallback = outcome.decision.used_fallback @@ -5153,6 +5226,15 @@ def main(argv: list[str] | None = None) -> int: config, repo, args.work_item_repository ) code_host = (config.get("code_cycle") or {}).get("code_host") + policy_code_host = code_host_of(config, args.cwd) + # Every publishing stage is audited on `origin` (#261). Only GitHub can be + # read for change-request state and protected branches until #238; any + # other code host still has its refs audited and the rest is `unverified`. + publication_auditor = (None if args.local_only or not args.cwd else PublicationAuditor( + args.cwd, repo, + GitHubHostReader(args.cwd) if (code_host or policy_code_host) == "github" else None, + # Only a configured default branch; otherwise `origin`'s live HEAD. + default_branch=base_branch_of(config, None))) # Asked on a terminal only; a run nobody watches never widens a sandbox # without an answer given in advance. consent = ConsentBroker( @@ -5193,7 +5275,8 @@ def main(argv: list[str] | None = None) -> int: work_item_provider=work_item_provider, work_item_repository=work_item_repository, code_host=code_host, - policy_code_host=code_host_of(config, args.cwd), + policy_code_host=policy_code_host, + publication_auditor=publication_auditor, trusted_authors=tuple(((config.get("code_cycle") or {}).get("review") or {}).get( "trusted_authors", [])), resolve_effort=((config.get("code_cycle") or {}).get("resolve") or {}).get("effort"), diff --git a/scripts/runtime.manifest b/scripts/runtime.manifest index a8d6b96..230deec 100644 --- a/scripts/runtime.manifest +++ b/scripts/runtime.manifest @@ -17,6 +17,7 @@ cycle_journal.py ci_gate.py reroute.py publication.py +publication_audit.py environment_recovery.py dependency_setup.py native_prerequisites.py diff --git a/scripts/telemetry.py b/scripts/telemetry.py index 52142d7..71dc60f 100644 --- a/scripts/telemetry.py +++ b/scripts/telemetry.py @@ -103,7 +103,11 @@ #: not accept stops with `manual_rereview_rejected`. Both payload-only. #: 23: quota exhaustion windows, keyed by repository, executor and model, so #: production continuations can avoid the exact target until its reset. -SCHEMA_VERSION = 23 +#: 24: the publication audit (#261). A cycle may stop with +#: `publication_violation`, an invariant on an audited ref or change request +#: failed during a publishing stage, or `publication_unverified`, the audited +#: state could not be read. Both payload-only. +SCHEMA_VERSION = 24 APP_DIRNAME = "code-cycle-toolkit" DATABASE_NAME = "telemetry.sqlite" @@ -149,6 +153,8 @@ def is_jev_model(value) -> bool: "permission_required", # The latest receipt is a manual rereview the resume cannot accept (schema 22). "manual_rereview_rejected", + # A publication invariant failed, or could not be read (schema 24). + "publication_violation", "publication_unverified", }) #: Who ran the review a cycle closed on (schema 22); `review_contract` defines them. diff --git a/scripts/windows-test-shards.json b/scripts/windows-test-shards.json index 89299f7..625e21e 100644 --- a/scripts/windows-test-shards.json +++ b/scripts/windows-test-shards.json @@ -54,6 +54,7 @@ "test_permission_view", "test_permission_lifecycle", "test_publication", + "test_publication_audit", "test_stage_dispatch", "test_stage_permissions", "test_support", @@ -116,6 +117,7 @@ "test_native_prerequisites", "test_profile_config", "test_publication", + "test_publication_audit", "test_review_contract", "test_router", "test_security_gate", diff --git a/tests/test_publication_audit.py b/tests/test_publication_audit.py new file mode 100644 index 0000000..7064690 --- /dev/null +++ b/tests/test_publication_audit.py @@ -0,0 +1,434 @@ +"""#261: the publication invariants, checked after every publishing stage. + +Every test uses a local bare `origin` and a fake `gh` that answers from memory, +so a merge, a close, a force-push, a push to the default branch or a deleted +ref is a real Git or code-host state change, never a mocked comparison. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlsplit + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +sys.path.insert(0, str(ROOT / "tests")) + +import publication_audit as pa # noqa: E402 +import run_cycle as rc # noqa: E402 +from test_run_cycle import COMPLETED, RunCycleTestCase, Talker, block # noqa: E402 + +REPO = "owner/api" +BRANCH = "task-7" + + +def git(cwd, *args) -> str: + return subprocess.run(["git", "-C", str(cwd), *args], check=True, + capture_output=True, text=True).stdout.strip() + + +class FakeGh: + """The GitHub API the audit reads, from memory: pulls and protection.""" + + def __init__(self) -> None: + self.protected = ["main"] + self.pulls: dict[int, dict] = {} + self.fail: str | None = None + self.calls: list[list[str]] = [] + + def open(self, number: int, head_ref: str = BRANCH) -> None: + self.pulls[number] = {"number": number, "state": "open", "merged": False, + "merged_at": None, "head": {"ref": head_ref}} + + def merge(self, number: int) -> None: + self.pulls[number].update(state="closed", merged=True, merged_at="2026-10-29T10:00:00Z") + + def close(self, number: int) -> None: + self.pulls[number].update(state="closed") + + def __call__(self, command, **_kw): + self.calls.append(command) + assert command[:2] == ["gh", "api"], command + path = command[-1] + if self.fail and self.fail in path: + return subprocess.CompletedProcess(command, 1, "", "HTTP 502") + parts = urlsplit(path) + query = parse_qs(parts.query) + if parts.path == f"repos/{REPO}/branches": + body = [[{"name": name} for name in self.protected]] + elif parts.path == f"repos/{REPO}/pulls": + head = unquote(query["head"][0]).partition(":")[2] + body = [[pull for pull in self.pulls.values() if pull["head"]["ref"] == head]] + elif parts.path.startswith(f"repos/{REPO}/pulls/"): + number = int(parts.path.rsplit("/", 1)[1]) + if number not in self.pulls: + return subprocess.CompletedProcess(command, 1, "", "HTTP 404") + body = self.pulls[number] + else: + return subprocess.CompletedProcess(command, 1, "", "unexpected path") + return subprocess.CompletedProcess(command, 0, json.dumps(body), "") + + +class Remote: + """A bare `origin` with `main`, `release` and the cycle branch, and a clone.""" + + def __init__(self, test: unittest.TestCase) -> None: + temporary = tempfile.TemporaryDirectory() + test.addCleanup(temporary.cleanup) + base = Path(temporary.name) + self.origin = base / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", str(self.origin)], check=True) + git(self.origin, "symbolic-ref", "HEAD", "refs/heads/main") + self.work = base / "work" + self.other = base / "other" + for clone in (self.work, self.other): + subprocess.run(["git", "clone", "-q", str(self.origin), str(clone)], + check=True, capture_output=True) + git(clone, "config", "user.name", "Cycle Test") + git(clone, "config", "user.email", "cycle@example.invalid") + git(clone, "config", "commit.gpgsign", "false") + self.commit(self.work, "initial") + git(self.work, "branch", "-M", "main") + git(self.work, "push", "-q", "origin", "main", "main:release") + git(self.work, "checkout", "-q", "-b", BRANCH) + self.commit(self.work, "work") + git(self.work, "push", "-q", "-u", "origin", BRANCH) + git(self.other, "fetch", "-q", "origin") + + @staticmethod + def commit(cwd: Path, message: str) -> str: + path = cwd / "file.txt" + path.write_text(f"{message}\n", encoding="utf-8") + git(cwd, "add", "file.txt") + git(cwd, "commit", "-qm", message) + return git(cwd, "rev-parse", "HEAD") + + def head(self, branch: str) -> str | None: + line = git(self.origin, "show-ref", f"refs/heads/{branch}") + return line.split()[0] if line else None + + # -- what a stage might do --------------------------------------------- + def push_forward(self) -> None: + self.commit(self.work, "fix") + git(self.work, "push", "-q", "origin", BRANCH) + + def force_push(self) -> None: + git(self.work, "reset", "-q", "--hard", "HEAD~1") + self.commit(self.work, "rewritten") + git(self.work, "push", "-q", "--force", "origin", BRANCH) + + def push_default(self) -> None: + git(self.work, "push", "-q", "origin", f"{BRANCH}:main") + + def delete(self, branch: str) -> None: + git(self.work, "push", "-q", "origin", "--delete", branch) + + def push_unrelated(self) -> None: + git(self.other, "checkout", "-q", "-b", "someone-else", "origin/main") + self.commit(self.other, "elsewhere") + git(self.other, "push", "-q", "origin", "someone-else") + + def push_from_elsewhere(self) -> None: + """A force-push whose commit only the code host has.""" + git(self.other, "checkout", "-q", "-B", "rewrite", "origin/main") + self.commit(self.other, "foreign") + git(self.other, "push", "-q", "--force", "origin", f"rewrite:{BRANCH}") + + +class AuditorTests(unittest.TestCase): + def setUp(self) -> None: + self.remote = Remote(self) + self.gh = FakeGh() + self.gh.open(4) + self.gh.protected = ["main", "release"] + self.auditor = pa.PublicationAuditor( + str(self.remote.work), REPO, pa.GitHubHostReader(run=self.gh)) + + def audit(self, change, *, request: str | None = "4", host=True): + auditor = self.auditor if host else pa.PublicationAuditor(str(self.remote.work), REPO, None) + before = auditor.capture(checkout_branch=BRANCH, change_request_id=request) + change() + return auditor.verify(before, change_request_id=request) + + def kinds(self, result) -> list[tuple[str, str]]: + return [(breach.kind, breach.ref) for breach in result.breaches] + + def test_a_fast_forward_push_and_a_comment_pass(self) -> None: + old = self.remote.head(BRANCH) + result = self.audit(self.remote.push_forward) + self.assertEqual(pa.PASSED, result.status) + evidence = result.evidence() + self.assertEqual({"before": old, "after": self.remote.head(BRANCH)}, + evidence["refs"][BRANCH]) + self.assertEqual({"before": "OPEN", "after": "OPEN"}, evidence["change_requests"]["4"]) + self.assertEqual(["main", "release"], evidence["protected"]) + + def test_a_merged_change_request_is_a_breach(self) -> None: + result = self.audit(lambda: self.gh.merge(4)) + self.assertEqual(pa.VIOLATED, result.status) + self.assertEqual([(pa.MERGED, "change_request:4")], self.kinds(result)) + self.assertEqual(("OPEN", "MERGED"), + (result.breaches[0].before, result.breaches[0].after)) + + def test_a_closed_change_request_is_a_breach(self) -> None: + result = self.audit(lambda: self.gh.close(4)) + self.assertEqual([(pa.CLOSED, "change_request:4")], self.kinds(result)) + + def test_a_force_push_to_the_cycle_branch_is_a_breach_with_its_commits(self) -> None: + old = self.remote.head(BRANCH) + result = self.audit(self.remote.force_push) + (breach,) = result.breaches + self.assertEqual((pa.NON_FAST_FORWARD, f"refs/heads/{BRANCH}"), (breach.kind, breach.ref)) + self.assertEqual((old, self.remote.head(BRANCH)), (breach.before, breach.after)) + self.assertEqual((old,), breach.commits_removed) + self.assertEqual((self.remote.head(BRANCH),), breach.commits_added) + + def test_a_force_push_from_elsewhere_is_fetched_and_compared(self) -> None: + result = self.audit(self.remote.push_from_elsewhere) + self.assertEqual([(pa.NON_FAST_FORWARD, f"refs/heads/{BRANCH}")], self.kinds(result)) + # Into FETCH_HEAD only: no ref was left behind in the checkout. + self.assertNotIn("rewrite", git(self.remote.work, "for-each-ref", "--format=%(refname)")) + + def test_any_move_of_the_default_branch_is_a_breach_even_a_fast_forward(self) -> None: + result = self.audit(self.remote.push_default) + self.assertEqual([(pa.MOVED, "refs/heads/main")], self.kinds(result)) + + def test_a_deleted_audited_ref_is_a_breach(self) -> None: + for branch in ("release", BRANCH): + with self.subTest(branch=branch): + if branch == BRANCH: + self.gh.pulls.clear() + result = self.audit(lambda: self.remote.delete(branch), request=None) + self.assertIn((pa.DELETED, f"refs/heads/{branch}"), self.kinds(result)) + + def test_a_push_to_an_unrelated_branch_passes(self) -> None: + result = self.audit(self.remote.push_unrelated) + self.assertEqual(pa.PASSED, result.status) + self.assertNotIn("someone-else", result.evidence()["refs"]) + + def test_an_unreadable_protected_branch_query_is_never_read_as_none(self) -> None: + self.gh.fail = "/branches" + with self.assertRaisesRegex(pa.AuditUnreadable, "exited 1"): + self.auditor.capture(checkout_branch=BRANCH, change_request_id="4") + + def test_an_unreadable_change_request_after_the_stage_fails_closed(self) -> None: + before = self.auditor.capture(checkout_branch=BRANCH, change_request_id="4") + self.gh.fail = "/pulls" + with self.assertRaises(pa.AuditUnreadable): + self.auditor.verify(before, change_request_id="4") + + def test_an_implementation_may_create_its_branch_and_an_open_change_request(self) -> None: + self.gh.pulls.clear() + before = self.auditor.capture(checkout_branch="task-8") + git(self.remote.work, "push", "-q", "origin", f"{BRANCH}:task-8") + self.gh.open(5, "task-8") + self.assertEqual(pa.PASSED, self.auditor.verify(before, change_request_id="5").status) + + def test_an_implementation_whose_new_change_request_is_merged_fails(self) -> None: + self.gh.pulls.clear() + before = self.auditor.capture(checkout_branch="task-8") + git(self.remote.work, "push", "-q", "origin", f"{BRANCH}:task-8") + self.gh.open(5, "task-8") + self.gh.merge(5) + result = self.auditor.verify(before) + self.assertEqual([(pa.MERGED, "change_request:5")], self.kinds(result)) + + def test_a_configured_default_branch_is_the_one_audited(self) -> None: + auditor = pa.PublicationAuditor(str(self.remote.work), REPO, None, + default_branch="release") + before = auditor.capture(checkout_branch=BRANCH) + git(self.remote.work, "push", "-q", "origin", f"{BRANCH}:release") + result = auditor.verify(before) + self.assertEqual("release", before.default_branch) + self.assertEqual([(pa.MOVED, "refs/heads/release")], self.kinds(result)) + + def test_an_empty_remote_has_no_default_branch_to_audit_and_says_so(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + origin, work = Path(temporary) / "empty.git", Path(temporary) / "work" + subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True) + subprocess.run(["git", "clone", "-q", str(origin), str(work)], + check=True, capture_output=True) + auditor = pa.PublicationAuditor(str(work), REPO, None) + before = auditor.capture(checkout_branch=BRANCH) + self.assertIsNone(before.default_branch) + result = auditor.verify(before) + self.assertEqual(pa.UNVERIFIED, result.status) + self.assertIn(pa.DEFAULT_BRANCH, result.unverified) + + def test_the_change_request_head_is_the_cycle_branch(self) -> None: + before = self.auditor.capture(checkout_branch="local-name", change_request_id="4") + self.assertEqual(BRANCH, before.cycle_branch) + + def test_a_host_without_a_reader_is_unverified_but_its_refs_are_still_audited(self) -> None: + passed = self.audit(self.remote.push_forward, host=False) + self.assertEqual(pa.UNVERIFIED, passed.status) + self.assertEqual([pa.CHANGE_REQUEST_STATE, pa.PROTECTED_BRANCHES], + passed.evidence()["unverified"]) + forced = self.audit(self.remote.force_push, host=False) + self.assertEqual(pa.VIOLATED, forced.status) + pushed = self.audit(self.remote.push_default, host=False) + self.assertEqual([(pa.MOVED, "refs/heads/main")], self.kinds(pushed)) + + +class Actor(Talker): + """A fake executor whose stages change the code host before they answer.""" + + ROLE_OF = {"cc-implement-issue": "implement", "cc-initial-review": "review", + "cc-resolve-comments": "resolve", "cc-rereview": "rereview"} + + def __init__(self, name: str, **effects) -> None: + super().__init__(name) + self.effects = effects + self.answers: dict[str, str] = {} + + def spoken(self, task: str) -> str: + role = next(role for skill, role in self.ROLE_OF.items() if skill in task) + effect = self.effects.pop(role, None) + if effect is not None: + effect() + if role in self.answers: + return self.answers[role] + return COMPLETED.get(next(skill for skill in self.ROLE_OF if skill in task), + block("APPROVED")) + + +class CycleAuditTests(RunCycleTestCase): + def setUp(self) -> None: + super().setUp() + self.remote = Remote(self) + self.gh = FakeGh() + self.gh.open(4) + + def cycle(self, implementer, reviewer=None, *, host=True, **kw): + reviewer = reviewer or Actor("claude") + auditor = pa.PublicationAuditor( + str(self.remote.work), REPO, pa.GitHubHostReader(run=self.gh) if host else None) + return self.run_cycle(implementer, reviewer, cwd=str(self.remote.work), + publication_auditor=auditor, **kw), reviewer + + def resume(self, implementer, **kw): + return self.cycle(implementer, start_from="resolve", change_request_id="4", **kw) + + def closing(self) -> dict: + return [row for row in self.rows() + if row["payload"].get("record_kind") == "cycle"][-1]["payload"] + + def assert_violation(self, report, reviewer, kind: str, ref: str) -> None: + self.assertEqual("publication_violation", report.stop_reason) + self.assertEqual("publication_violation", self.closing()["stop_reason"]) + self.assertIn("changed outside the stage?", report.stopped_because) + self.assertEqual([], reviewer.dispatched) + self.assertEqual(1, len(report.stages)) + evidence = report.publication_audit + self.assertEqual(pa.VIOLATED, evidence["status"]) + self.assertIn({"kind": kind, "ref": ref}, + [{"kind": item["kind"], "ref": item["ref"]} for item in evidence["breaches"]]) + + def test_a_stage_that_merges_the_change_request_stops_the_cycle(self) -> None: + report, reviewer = self.cycle(Actor("codex", implement=lambda: self.gh.merge(4))) + self.assert_violation(report, reviewer, pa.MERGED, "change_request:4") + self.assertEqual({"before": "OPEN", "after": "MERGED"}, + report.publication_audit["change_requests"]["4"]) + + def test_a_force_push_a_default_push_and_a_deleted_ref_stop_the_cycle(self) -> None: + cases = { + "force_push": (lambda remote: remote.force_push(), pa.NON_FAST_FORWARD, + f"refs/heads/{BRANCH}"), + "push_default": (lambda remote: remote.push_default(), pa.MOVED, "refs/heads/main"), + "delete": (lambda remote: remote.delete(BRANCH), pa.DELETED, f"refs/heads/{BRANCH}"), + } + for name, (effect, kind, ref) in cases.items(): + with self.subTest(name): + remote = self.remote = Remote(self) + report, reviewer = self.cycle(Actor("codex", implement=lambda: effect(remote))) + self.assert_violation(report, reviewer, kind, ref) + breach = next(item for item in report.publication_audit["breaches"] + if item["ref"] == ref) + self.assertIsNotNone(breach["before"]) + + def test_a_breach_wins_over_a_blocked_report(self) -> None: + implementer = Actor("codex", implement=lambda: self.gh.merge(4)) + implementer.answers["implement"] = block("BLOCKED", reason="incidental refusal") + report, reviewer = self.cycle(implementer) + self.assert_violation(report, reviewer, pa.MERGED, "change_request:4") + + def test_a_normal_resolution_that_pushes_forward_passes(self) -> None: + report, reviewer = self.resume(Actor("codex", resolve=self.remote.push_forward)) + self.assertEqual(rc.APPROVED_END, report.status) + self.assertEqual(["resolve", "rereview"], [stage.role for stage in report.stages]) + self.assertEqual(pa.PASSED, report.publication_audit["status"]) + self.assertEqual("rereview", report.publication_audit["role"]) + + def test_a_push_to_an_unrelated_branch_during_the_stage_passes(self) -> None: + report, _ = self.resume(Actor("codex", resolve=self.remote.push_unrelated)) + self.assertEqual(rc.APPROVED_END, report.status) + + def test_a_reviewer_that_merges_stops_the_cycle(self) -> None: + reviewer = Actor("claude", rereview=lambda: self.gh.merge(4)) + report, _ = self.cycle(Actor("codex"), reviewer, start_from="resolve", + change_request_id="4") + self.assertEqual("publication_violation", report.stop_reason) + self.assertEqual(["resolve", "rereview"], [stage.role for stage in report.stages]) + + def test_an_unreadable_code_host_stops_before_the_stage(self) -> None: + self.gh.fail = "/branches" + implementer = Actor("codex") + report, _ = self.cycle(implementer) + self.assertEqual("publication_unverified", report.stop_reason) + self.assertEqual("BLOCKED", report.status) + self.assertEqual([], implementer.dispatched) + + def test_another_code_host_is_reported_unverified_and_still_audits_refs(self) -> None: + report, _ = self.resume(Actor("codex", resolve=self.remote.push_forward), host=False) + self.assertEqual(rc.APPROVED_END, report.status) + self.assertIn("unverified", report.explain()) + self.assertEqual(pa.UNVERIFIED, report.publication_audit["status"]) + report, _ = self.resume(Actor("codex", resolve=self.remote.force_push), host=False) + self.assertEqual("publication_violation", report.stop_reason) + + +class WiringTests(unittest.TestCase): + def test_main_audits_every_cycle_that_publishes(self) -> None: + seen = [] + + class Report: + status = rc.APPROVED_END + + def explain(self) -> str: + return "scripted" + + def decisions_record(self) -> None: + return None + + def run_cycle(*_args, **kw): + seen.append(kw["publication_auditor"]) + return Report() + + with tempfile.TemporaryDirectory() as temporary: + config = Path(temporary) / ".code-cycle.yml" + config.write_text("code_cycle:\n code_host: github\n", encoding="utf-8") + original = rc.run_cycle + rc.run_cycle = run_cycle + try: + with contextlib.redirect_stdout(io.StringIO()): + rc.main(["--repo", REPO, "--task", "API-7", "--workspace", "current", + "--config", str(config), + "--database", str(Path(temporary) / "t.sqlite")]) + except SystemExit: + self.skipTest("this checkout cannot run the driver's entry point") + finally: + rc.run_cycle = original + (auditor,) = seen + self.assertIsInstance(auditor, pa.PublicationAuditor) + self.assertIsInstance(auditor.host, pa.GitHubHostReader) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_work_item_outcomes.py b/tests/test_work_item_outcomes.py index 4fcd1eb..6c8c77c 100644 --- a/tests/test_work_item_outcomes.py +++ b/tests/test_work_item_outcomes.py @@ -70,7 +70,7 @@ def test_schema_fourteen_records_transitions_and_deduplicates_snapshots(self) -> self.assertEqual("unchanged", repeated["reason"]) self.assertFalse(stale["created"]) self.assertEqual("stale", stale["reason"]) - self.assertEqual(23, tm.SCHEMA_VERSION) + self.assertEqual(24, tm.SCHEMA_VERSION) self.assertEqual(1, len(self.store.work_item_disposition_events("owner/repo"))) event = self.store.latest_work_item_disposition("owner/repo", "github", "owner/repo", "120") self.assertEqual(["cycle-1"], event["cycle_ids"])