From 6a433339c07dce9dd9df543828f19b545e18374a Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:53:59 +0200 Subject: [PATCH 1/7] Plan toolkit 0.4.7 delivery --- PLANS.md | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/PLANS.md b/PLANS.md index 76b52ad..6177b7e 100644 --- a/PLANS.md +++ b/PLANS.md @@ -2,6 +2,100 @@ Use this file for active, blocked, or recently completed execution work. Update it before implementation and before handoff or commit. Older completed plans are indexed in [the execution-history archive](docs/engineering/execution_history/README.md). +## Active Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7 + +Status: active; implementation not started +Owner: Codex +Last Updated: 2026-08-10 +Release Classification: release-required +Target Stable Version: 0.4.7 +Tracking Issues: #70, #71 + +### Goal + +Ship issues #70 and #71 as toolkit 0.4.7: publish actionable GitLab +suggestions only when they are proven to replace one contiguous range in the +reviewed immutable head, and add conservative default-on automatic approval +that is bound to the exact reviewed merge-request SHA. Preserve a successfully +published advisory review when approval management is ineligible, stale, or +fails, and complete the release only after immutable external evidence has been +independently read back. + +### Decisions + +- Use one feature branch and one protected feature pull request, with separate + signed implementation checkpoints for #70, #71, and release-lifecycle + hardening. Keep both issues open until stable external delivery is verified. +- Keep provider-neutral decisions in typed core objects and GitLab HTTP/state + transitions behind the provider adapter. Add no runtime dependency, public + evidence command, permanent OCR harness, telemetry expansion, or tunable + approval-policy variables. +- Make `OCR_AUTO_APPROVE` default on with the established boolean vocabulary. + An invalid value disables approval for that run. Encode the initial policy in + code and fail closed when authoritative completeness or typed finding + metadata cannot be proven. +- After the complete feature implementation is committed, run exactly one real + local OCR 1.8.10 review through `uv run ocr-ci review` over + `origin/main..HEAD`. Require the built-in `ocr_toolkit_evidence` MCP receipt, + do not post to GitLab, fix actionable findings, and then use deterministic + validation and self-review rather than a second OCR run. +- Do not run Codex Security. Existing repository CI security checks and the + checksum-pinned local Gitleaks gate remain required. +- Redesign the durable release lifecycle so the release PR is the final + repository mutation without preclaiming external facts. Bind publication to + the exact reviewed tree and emit an immutable machine-readable release + receipt; close #70/#71 only after independent registry, provenance, tag, + Release, receipt, hash, and supported-Python readback succeeds. + +### Work Queue + +1. [ ] Implement typed contiguous-range suggestion validation, immutable-head + proof, bounded omission reasons, documentation, complete regressions, review, + and the #70 checkpoint commit. +2. [ ] Implement typed auto-approval configuration and policy, exact-SHA GitLab + synchronization/write/readback, managed own-user approval receipts, + documentation, complete regressions, review, and the #71 checkpoint commit. +3. [ ] Replace the redundant post-release closure-PR contract with exact-tree + release authorization and deterministic `ocr-toolkit.release-receipt/v1` + evidence; update durable rules, recovery behavior, tests, and the lifecycle + checkpoint commit. +4. [ ] Reconcile this plan, roadmap table/diagram, backlog, and current-state + documentation against the implemented code. Run focused tests, the synthetic + GitLab E2E, Python 3.12 quality, Towncrier draft, workflow/document/privacy + checks, and `git diff --check`. +5. [ ] Commit the complete feature tip and run one local toolkit-owned OCR review + with private result/stderr artifacts, no GitLab posting, and verified nonzero + built-in MCP use. Correct findings and complete final self-review without a + second OCR or Codex Security run. +6. [ ] Run deterministic post-review validation and pinned local Gitleaks over + the unpublished history, push the exact reviewed branch, open one feature PR, + resolve every conversation, pass protected checks, and squash-merge. +7. [ ] Independently verify the exact TestPyPI development artifacts, hashes, + provenance, and supported-Python installs before preparing `release/v0.4.7`. +8. [ ] Prepare and validate the final release PR, consuming fragments 69, 70, + and 71 and reconciling repository-side planning truth without claiming + publication that has not happened. +9. [ ] Merge the release PR only after exact-head protected checks. Verify stable + TestPyPI/PyPI artifacts, provenance/attestations, annotated tag, immutable + GitHub Release and release receipt, hashes, and Python 3.12-3.14 installs. + Record receipts and close #70/#71 without another repository PR. + +### Initial Evidence + +- Clean synchronized `main` is `bb8827148f13b17b209495788ac4f7b15573a168`; + stable toolkit 0.4.6 is published and `.next-version` targets 0.4.7. +- Issues #70 and #71 are open. Current suggestion handling proves exact no-op + equality but does not prove changed `suggestion_code` applies to + `existing_code` at the reviewed range; the toolkit has no approval-management + transaction yet. +- The effective local binary is Open Code Review 1.8.10 and the checkout's + `uv run ocr-ci review` path owns evidence collection, compact bootstrap, + mandatory `ocr_toolkit_evidence` composition, use verification, and the + private receipt. +- Current release guidance still requires a documentation-only closure PR and + release authorization does not bind publication to the reviewed head tree and + exact checks. Both are explicit scope of the lifecycle checkpoint. + ## Completed Plan: Reconcile 0.4.6 lifecycle, architecture, and backlog truth Status: completed; validated documentation/process PR handoff From ff0afb9f1bd3c407434d3c1dcfeb8f1aa1004116 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:02:16 +0200 Subject: [PATCH 2/7] Prove GitLab suggestion ranges before posting --- PLANS.md | 26 +++- changelog.d/70.bugfix.md | 2 + docs/operations.md | 11 ++ docs/security.md | 4 + src/ocr_toolkit/posting/formatting.py | 85 +++++----- src/ocr_toolkit/posting/suggestions.py | 207 +++++++++++++++++++++++++ src/ocr_toolkit/posting/workflow.py | 80 +++------- tests/test_posting_helpers.py | 119 +++++++------- tests/test_posting_suggestions.py | 149 ++++++++++++++++++ 9 files changed, 526 insertions(+), 157 deletions(-) create mode 100644 changelog.d/70.bugfix.md create mode 100644 src/ocr_toolkit/posting/suggestions.py create mode 100644 tests/test_posting_suggestions.py diff --git a/PLANS.md b/PLANS.md index 6177b7e..489d625 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,7 +4,7 @@ Use this file for active, blocked, or recently completed execution work. Update ## Active Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7 -Status: active; implementation not started +Status: active; issue #70 checkpoint complete Owner: Codex Last Updated: 2026-08-10 Release Classification: release-required @@ -49,7 +49,7 @@ independently read back. ### Work Queue -1. [ ] Implement typed contiguous-range suggestion validation, immutable-head +1. [x] Implement typed contiguous-range suggestion validation, immutable-head proof, bounded omission reasons, documentation, complete regressions, review, and the #70 checkpoint commit. 2. [ ] Implement typed auto-approval configuration and policy, exact-SHA GitLab @@ -96,6 +96,28 @@ independently read back. release authorization does not bind publication to the reviewed head tree and exact checks. Both are explicit scope of the lifecycle checkpoint. +### Issue #70 Checkpoint + +- GitLab suggestion applicability is now a closed typed decision rather than a + hidden mutation on the untrusted OCR comment. The renderer accepts only an + already-proven replacement; impossible state/field combinations fail at the + typed boundary. +- Validation binds a safe repository-relative path and inclusive range to one + bounded immutable head blob, normalizes CRLF/CR and one terminal newline, and + requires exact `existing_code` agreement before a changed replacement becomes + actionable. Existing exact no-op suppression remains available even for the + older no-`existing_code` result shape. +- Synthetic omission bridges across common comment syntaxes, diff-prefixed + replacements, quick actions, unsafe fences, oversized values, stale source, + and invalid ranges retain the finding but produce only a closed non-sensitive + omission reason. Fallback notes never render an actionable suggestion fence. +- Focused Ruff and strict mypy pass. The complete posting/suggestion regression + set passes 123 tests, including valid one-line and multiline replacements, + newline equivalence, missing/stale source, invalid/out-of-bounds ranges, + omission variants, diff prefixes, no-op behavior, typed invariants, unsafe + paths, and workflow-level proof that only the apply fence is withheld. + Towncrier 0.4.7 draft and `git diff --check` pass. + ## Completed Plan: Reconcile 0.4.6 lifecycle, architecture, and backlog truth Status: completed; validated documentation/process PR handoff diff --git a/changelog.d/70.bugfix.md b/changelog.d/70.bugfix.md new file mode 100644 index 0000000..2ee93ec --- /dev/null +++ b/changelog.d/70.bugfix.md @@ -0,0 +1,2 @@ +Publish an actionable GitLab suggestion only when `existing_code` proves that the replacement applies to one contiguous range in the immutable reviewed head. +Retain the explanatory finding, with a bounded non-sensitive omission reason, when a replacement is stale, malformed, multi-region, diff-prefixed, or otherwise unverifiable. diff --git a/docs/operations.md b/docs/operations.md index 573eca0..3a889b0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -10,6 +10,17 @@ The toolkit reads the previous OCR-owned notes and discussions before it writes - bounded fallback notes when GitLab cannot accept a position, for example after relevant lines moved outside the current diff; - one `## Open Code Review` note that separates review health, published findings, and incomplete coverage, with operational posting, commit, token/tool, and used-MCP metadata under a collapsed technical-details disclosure. +An actionable GitLab suggestion is stricter than an ordinary finding. The +toolkit reads the exact reviewed head blob and requires `existing_code` to +match the stated inclusive line range before it renders a replacement fence. +For this comparison CRLF and CR are normalized to LF and one optional terminal +newline is ignored. The replacement must describe one contiguous edit: a +synthetic ellipsis bridge, unified-diff-prefixed text, unsafe Markdown fence, +quick action, invalid range, or unavailable source suppresses only the +actionable fence. The explanatory finding remains visible with a bounded reason +that does not reproduce repository content. Exact no-op suggestions are also +suppressed. + `OCR_MAX_POST_COMMENTS` limits individually published findings. The default is 50 and the hard limit is 200. Omitted findings are counted in the summary rather than silently disappearing. The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews independently from whether findings were published. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Zero-valued counters and configured-but-unused MCP servers are omitted. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; inline findings retain quiet text-only severity and category fields. diff --git a/docs/security.md b/docs/security.md index 419cff7..adad0d6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -7,6 +7,10 @@ The toolkit bridges four trust domains: repository content, OCR and its LLM/MCP - Repository reads are bounded, rooted, symlink-aware, and exclude common dependency/build trees. - Review-invocation metadata is provider-normalized from a closed allowlist. GitLab evidence includes only bounded numeric project, pipeline, job, and merge-request identifiers; URLs, refs, tokens, and arbitrary environment values are not collected. Invocation facts carry a distinct trust class and are not treated as repository or toolkit assertions. - Generated Markdown escapes control characters and neutralizes GitLab quick actions. +- Actionable GitLab suggestions require an exact `existing_code` match against + one bounded range in the immutable reviewed head blob. Multi-region omission + markers, diff-prefixed replacements, unsafe fences, and unverifiable ranges + retain the explanatory finding but cannot create an apply button. - Secrets and credential-shaped values are redacted before operational output. - OCR result and provider response reads have byte limits. - GitLab notes enforce both UTF-8 byte limits and Python character limits. diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index fdb4a12..5eeb139 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -34,14 +34,13 @@ MAX_REVIEWER_GUIDE_LABEL_CHARS, MAX_REVIEWER_GUIDE_LOCATION_CHARS, MAX_REVIEWER_GUIDE_TEXT_CHARS, - MAX_SUGGESTION_CODE_CHARS, - MAX_SUGGESTION_SPAN_LINES, MAX_TOOL_CALL_NAME_CHARS, MAX_TOOL_CALL_SUMMARY_TOOLS, SUGGESTION_HEADER, post_emoji, post_mode, ) +from ocr_toolkit.posting.suggestions import SuggestionDecision, SuggestionState OCR_FINDING_CATEGORIES = { "bug", @@ -85,22 +84,6 @@ } -def suggestion_range_suffix(comment: dict[str, Any]) -> str: - """Return a GitLab suggestion range suffix.""" - - end_line = line_number(comment.get("end_line") or comment.get("line")) - start_line = line_number(comment.get("start_line") or comment.get("line") or end_line) - - if start_line <= 0 or end_line <= 0 or start_line > end_line: - return "" - - span = end_line - start_line - if span > MAX_SUGGESTION_SPAN_LINES: - return "" - - return f"-0+{span}" - - def inline_code(value: str) -> str: """Return a Markdown inline-code representation safe for backticks.""" @@ -136,34 +119,33 @@ def format_finding_tags(comment: dict[str, Any], *, emoji: bool | None = None) - return " · ".join(tags) -def format_suggestion_block(comment: dict[str, Any]) -> str: - """Return a GitLab suggestion block if OCR supplied replacement code.""" +def format_suggestion_block(decision: SuggestionDecision) -> str: + """Render one previously validated GitLab suggestion decision.""" - suggestion = code_text(comment.get("suggestion_code")) - if not suggestion.strip() or comment.get("_ocr_suggestion_noop") is True: - return "" - - if "```" in suggestion: + if decision.state in {SuggestionState.ABSENT, SuggestionState.NO_OP}: return "" + if decision.state is SuggestionState.OMITTED: + return f"\n\nSuggestion block was omitted because {decision.omission_message}." + return ( + f"\n\n{SUGGESTION_HEADER}\n```suggestion:{decision.range_suffix}\n" + f"{decision.replacement}\n```" + ) - if any(line.lstrip().startswith("/") for line in suggestion.splitlines()): - return "" - if len(suggestion) > MAX_SUGGESTION_CODE_CHARS: - return ( - "\n\nSuggestion block was omitted because the generated replacement " - "was too large to publish safely." - ) +def format_suggestion_omission(decision: SuggestionDecision) -> str: + """Render a bounded explanation for a withheld actionable suggestion.""" - range_suffix = suggestion_range_suffix(comment) - if not range_suffix: + if decision.state is not SuggestionState.OMITTED: return "" - - return f"\n\n{SUGGESTION_HEADER}\n```suggestion:{range_suffix}\n{suggestion}\n```" + return f"\n\nSuggestion block was omitted because {decision.omission_message}." def format_inline_comment( - comment: dict[str, Any], include_suggestion: bool = True, *, emoji: bool | None = None + comment: dict[str, Any], + include_suggestion: bool = True, + *, + suggestion_decision: SuggestionDecision | None = None, + emoji: bool | None = None, ) -> str: """Format one OCR comment as Markdown for an inline GitLab discussion.""" @@ -173,12 +155,19 @@ def format_inline_comment( tags = format_finding_tags(comment, emoji=emoji) body = f"{tags}\n\n{content}" if tags else content if include_suggestion: - body += format_suggestion_block(comment) + body += format_suggestion_block( + suggestion_decision or SuggestionDecision(SuggestionState.ABSENT) + ) return body -def format_fallback_comment(comment: dict[str, Any], *, emoji: bool | None = None) -> str: +def format_fallback_comment( + comment: dict[str, Any], + *, + suggestion_decision: SuggestionDecision | None = None, + emoji: bool | None = None, +) -> str: """Format an OCR comment for a fallback non-inline MR note.""" path = clean_text(comment.get("path")) or "unknown" @@ -202,10 +191,13 @@ def format_fallback_comment(comment: dict[str, Any], *, emoji: bool | None = Non f"{format_inline_comment(comment, include_suggestion=False, emoji=emoji)}" ) + decision = suggestion_decision or SuggestionDecision(SuggestionState.ABSENT) + body += format_suggestion_omission(decision) + existing = code_text(comment.get("existing_code")) suggestion = code_text(comment.get("suggestion_code")) - if existing.strip() and suggestion.strip() and comment.get("_ocr_suggestion_noop") is not True: + if existing.strip() and suggestion.strip() and decision.state is not SuggestionState.NO_OP: body += "\n\n
Suggested change details\n\n" body += "**Before:**\n" body += markdown_code_block( @@ -228,16 +220,23 @@ def format_fallback_comment(comment: dict[str, Any], *, emoji: bool | None = Non def format_fallback_comment_chunks( - comments: Sequence[dict[str, Any]], *, emoji: bool | None = None + comments: Sequence[tuple[dict[str, Any], SuggestionDecision]], + *, + emoji: bool | None = None, ) -> list[str]: """Split fallback comments into safe chunks before publishing MR notes.""" chunks: list[str] = [] current = "" - for comment in comments: + for comment, suggestion_decision in comments: item = truncate_note_body( - format_fallback_comment(comment, emoji=emoji), max_chars=FALLBACK_NOTE_CHUNK_BUDGET + format_fallback_comment( + comment, + suggestion_decision=suggestion_decision, + emoji=emoji, + ), + max_chars=FALLBACK_NOTE_CHUNK_BUDGET, ) separator = "\n\n---\n\n" if current else "" diff --git a/src/ocr_toolkit/posting/suggestions.py b/src/ocr_toolkit/posting/suggestions.py new file mode 100644 index 0000000..e0d4309 --- /dev/null +++ b/src/ocr_toolkit/posting/suggestions.py @@ -0,0 +1,207 @@ +"""Validate OCR replacements before rendering actionable GitLab suggestions.""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from pathlib import PurePosixPath +from typing import Any + +from ocr_toolkit.posting.comments import line_number +from ocr_toolkit.posting.settings import ( + MAX_SUGGESTION_CODE_CHARS, + MAX_SUGGESTION_SPAN_LINES, +) + + +class SuggestionState(str, Enum): + """Closed rendering states for an OCR-provided replacement.""" + + ABSENT = "absent" + ACTIONABLE = "actionable" + NO_OP = "no_op" + OMITTED = "omitted" + + +class SuggestionOmission(str, Enum): + """Non-sensitive reasons why an actionable suggestion was withheld.""" + + INVALID_PATH = "invalid_path" + INVALID_RANGE = "invalid_range" + RANGE_TOO_LARGE = "range_too_large" + SOURCE_UNAVAILABLE = "source_unavailable" + RANGE_OUT_OF_BOUNDS = "range_out_of_bounds" + MISSING_EXISTING_CODE = "missing_existing_code" + EXISTING_CODE_MISMATCH = "existing_code_mismatch" + MALFORMED_REPLACEMENT = "malformed_replacement" + REPLACEMENT_TOO_LARGE = "replacement_too_large" + SYNTHETIC_OMISSION = "synthetic_omission" + DIFF_PREFIXED = "diff_prefixed" + UNSAFE_MARKDOWN = "unsafe_markdown" + QUICK_ACTION = "quick_action" + + +OMISSION_MESSAGES = { + SuggestionOmission.INVALID_PATH: "the repository path could not be verified", + SuggestionOmission.INVALID_RANGE: "the source range was invalid", + SuggestionOmission.RANGE_TOO_LARGE: "the source range exceeded the safe line limit", + SuggestionOmission.SOURCE_UNAVAILABLE: "the reviewed source blob was unavailable", + SuggestionOmission.RANGE_OUT_OF_BOUNDS: "the source range was outside the reviewed blob", + SuggestionOmission.MISSING_EXISTING_CODE: "the original source text was missing", + SuggestionOmission.EXISTING_CODE_MISMATCH: ( + "the original source text did not match the reviewed range" + ), + SuggestionOmission.MALFORMED_REPLACEMENT: "the replacement text was malformed", + SuggestionOmission.REPLACEMENT_TOO_LARGE: "the replacement exceeded the safe size limit", + SuggestionOmission.SYNTHETIC_OMISSION: ( + "the replacement contained a synthetic omission marker" + ), + SuggestionOmission.DIFF_PREFIXED: "the replacement looked like unified diff content", + SuggestionOmission.UNSAFE_MARKDOWN: "the replacement contained an unsafe Markdown fence", + SuggestionOmission.QUICK_ACTION: "the replacement contained a GitLab quick action", +} + + +@dataclass(frozen=True, slots=True) +class SuggestionDecision: + """A validated decision consumed by GitLab Markdown rendering.""" + + state: SuggestionState + replacement: str = "" + range_suffix: str = "" + omission: SuggestionOmission | None = None + + def __post_init__(self) -> None: + """Reject impossible state combinations at the renderer boundary.""" + + if self.state is SuggestionState.ACTIONABLE: + if not self.replacement or not re.fullmatch(r"-0\+\d+", self.range_suffix): + raise ValueError("actionable suggestion requires replacement and range") + if self.omission is not None: + raise ValueError("actionable suggestion cannot carry an omission") + return + if self.state is SuggestionState.OMITTED: + if self.omission is None or self.replacement or self.range_suffix: + raise ValueError("omitted suggestion requires only a closed omission reason") + return + if self.replacement or self.range_suffix or self.omission is not None: + raise ValueError("absent and no-op suggestions cannot carry rendering fields") + + @property + def omission_message(self) -> str: + """Return a bounded public explanation without repository content.""" + + if self.omission is None: + return "" + return OMISSION_MESSAGES[self.omission] + + +ELLIPSIS_BRIDGE_RE = re.compile( + r"^(?:(?:#|//|;|--|/\*|\*||\*\)))?$" +) + + +def normalize_replacement(value: str) -> str: + """Normalize transport line endings and one optional terminal newline.""" + + normalized = value.replace("\r\n", "\n").replace("\r", "\n") + return normalized[:-1] if normalized.endswith("\n") else normalized + + +def safe_repository_path(path: str) -> bool: + """Return whether an OCR path is safe after an immutable Git revision.""" + + parts = path.split("/") + pure = PurePosixPath(path) + return bool( + path + and not pure.is_absolute() + and "\\" not in path + and all(part not in {"", ".", ".."} for part in parts) + and not any(character == "\x7f" or ord(character) < 32 for character in path) + ) + + +def _omitted(reason: SuggestionOmission) -> SuggestionDecision: + """Build an omitted decision from the closed reason vocabulary.""" + + return SuggestionDecision(SuggestionState.OMITTED, omission=reason) + + +def _replacement_shape_omission(replacement: str) -> SuggestionOmission | None: + """Return why replacement text cannot represent one safe contiguous edit.""" + + if len(replacement) > MAX_SUGGESTION_CODE_CHARS: + return SuggestionOmission.REPLACEMENT_TOO_LARGE + if "```" in replacement: + return SuggestionOmission.UNSAFE_MARKDOWN + + lines = replacement.splitlines() + if any(ELLIPSIS_BRIDGE_RE.fullmatch(line.strip()) for line in lines): + return SuggestionOmission.SYNTHETIC_OMISSION + if any(line.lstrip().startswith("/") for line in lines): + return SuggestionOmission.QUICK_ACTION + + nonblank = [line for line in lines if line.strip()] + if nonblank and all(line.startswith(("+", "-")) for line in nonblank): + return SuggestionOmission.DIFF_PREFIXED + return None + + +def evaluate_suggestion( + comment: dict[str, Any], + path: str, + read_head_blob: Callable[[str], str | None], +) -> SuggestionDecision: + """Prove an OCR replacement applies to one range in the reviewed head blob.""" + + raw_replacement = comment.get("suggestion_code") + if raw_replacement is None or raw_replacement == "": + return SuggestionDecision(SuggestionState.ABSENT) + if not isinstance(raw_replacement, str) or not raw_replacement.strip(): + return _omitted(SuggestionOmission.MALFORMED_REPLACEMENT) + + if not safe_repository_path(path): + return _omitted(SuggestionOmission.INVALID_PATH) + + raw_start = comment.get("start_line") + raw_end = comment.get("end_line") + start = line_number(comment.get("line") if raw_start is None else raw_start) + end = line_number(comment.get("line") if raw_end is None else raw_end) + if start <= 0 or end < start: + return _omitted(SuggestionOmission.INVALID_RANGE) + span = end - start + if span > MAX_SUGGESTION_SPAN_LINES: + return _omitted(SuggestionOmission.RANGE_TOO_LARGE) + + source = read_head_blob(path) + if source is None: + return _omitted(SuggestionOmission.SOURCE_UNAVAILABLE) + source_lines = source.replace("\r\n", "\n").replace("\r", "\n").splitlines() + if end > len(source_lines): + return _omitted(SuggestionOmission.RANGE_OUT_OF_BOUNDS) + + selected = "\n".join(source_lines[start - 1 : end]) + replacement = normalize_replacement(raw_replacement) + if replacement == normalize_replacement(selected): + return SuggestionDecision(SuggestionState.NO_OP) + + raw_existing = comment.get("existing_code") + if not isinstance(raw_existing, str) or not raw_existing: + return _omitted(SuggestionOmission.MISSING_EXISTING_CODE) + if normalize_replacement(raw_existing) != normalize_replacement(selected): + return _omitted(SuggestionOmission.EXISTING_CODE_MISMATCH) + + shape_omission = _replacement_shape_omission(replacement) + if shape_omission is not None: + return _omitted(shape_omission) + + return SuggestionDecision( + SuggestionState.ACTIONABLE, + replacement=replacement, + range_suffix=f"-0+{span}", + ) diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 88eb468..ac3c4c1 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -8,7 +8,7 @@ import sys from collections import Counter from collections.abc import Sequence -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import Any from ocr_toolkit.common.git import isolated_git_environment, read_only_git_prefix @@ -27,7 +27,6 @@ code_text, comment_line, compact_escaped_text, - line_number, ) from ocr_toolkit.posting.formatting import ( format_fallback_comment_chunks, @@ -65,6 +64,11 @@ publish_failure_exit, rollback_current_run_comments, ) +from ocr_toolkit.posting.suggestions import ( + SuggestionDecision, + evaluate_suggestion, + safe_repository_path, +) from ocr_toolkit.result_contract import OcrResultContractError, ReviewOutcome, parse_result_outcome # Kept as a module-level compatibility seam for tests and external monkey-patching. @@ -121,20 +125,6 @@ def mr_head_sha() -> str: MAX_REMAP_DIFF_BYTES = 8_000_000 -def _safe_git_blob_path(path: str) -> bool: - """Return whether an OCR path is safe to bind after an immutable Git ref.""" - - parts = path.split("/") - pure = PurePosixPath(path) - return bool( - path - and not pure.is_absolute() - and "\\" not in path - and all(part not in {"", ".", ".."} for part in parts) - and not any(character == "\x7f" or ord(character) < 32 for character in path) - ) - - def _git_read_environment() -> dict[str, str]: """Return an isolated environment for untrusted-repository Git reads.""" @@ -318,7 +308,7 @@ def head_file_text( cache_key = (refs["head_sha"], path) if cache is not None and cache_key in cache: return cache[cache_key] - if not _safe_git_blob_path(path): + if not safe_repository_path(path): if cache is not None: cache[cache_key] = None return None @@ -352,36 +342,6 @@ def head_file_text( return text -def _normalized_replacement(value: str) -> str: - """Normalize transport line endings and one optional terminal newline.""" - - normalized = value.replace("\r\n", "\n").replace("\r", "\n") - return normalized[:-1] if normalized.endswith("\n") else normalized - - -def suggestion_matches_head_range( - refs: dict[str, str], - path: str, - comment: dict[str, Any], - cache: FileTextCache | None = None, -) -> bool: - """Return true only when a suggestion exactly reproduces the reviewed range.""" - - suggestion = code_text(comment.get("suggestion_code")) - start = line_number(comment.get("start_line") or comment.get("line")) - end = line_number(comment.get("end_line") or comment.get("line")) - if not suggestion or not path or start <= 0 or end < start or end - start > 200: - return False - source = head_file_text(refs, path, cache) - if source is None: - return False - lines = source.replace("\r\n", "\n").replace("\r", "\n").split("\n") - if end > len(lines): - return False - selected = "\n".join(lines[start - 1 : end]) - return _normalized_replacement(suggestion) == _normalized_replacement(selected) - - def unique_existing_code_line( refs: dict[str, str], path: str, @@ -602,7 +562,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: refs = get_diff_refs(config) inline_count = 0 - failed_comments: list[dict[str, Any]] = [] + failed_comments: list[tuple[dict[str, Any], SuggestionDecision]] = [] fallback_reasons: Counter[str] = Counter() diff_line_cache: DiffLineCache = {} file_line_cache: FileLineCache = {} @@ -637,14 +597,12 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: file=sys.stderr, ) - raw_comment["_ocr_suggestion_noop"] = bool( - refs - and suggestion_matches_head_range( - refs, - path, - raw_comment, - file_text_cache, - ) + suggestion_decision = evaluate_suggestion( + raw_comment, + path, + lambda candidate_path: ( + head_file_text(refs, candidate_path, file_text_cache) if refs else None + ), ) if not refs or not path or line <= 0: reason = inline_skip_reason(refs, path, line) @@ -654,14 +612,18 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: file=sys.stderr, ) fallback_reasons[reason] += 1 - failed_comments.append(raw_comment) + failed_comments.append((raw_comment, suggestion_decision)) continue inline_result = post_review_discussion( config=config, path=path, line=line, - body=format_inline_comment(raw_comment, emoji=emoji), + body=format_inline_comment( + raw_comment, + suggestion_decision=suggestion_decision, + emoji=emoji, + ), refs=refs, draft_note_ids=draft_note_ids, fingerprint=clean_text(raw_comment.get("_ocr_fingerprint")) or None, @@ -677,7 +639,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: file=sys.stderr, ) fallback_reasons["invalid_position"] += 1 - failed_comments.append(raw_comment) + failed_comments.append((raw_comment, suggestion_decision)) else: print( f"Inline posting failed reason=post_failed, path={path!r}, line={line!r}; " diff --git a/tests/test_posting_helpers.py b/tests/test_posting_helpers.py index ba9224d..497e0c5 100644 --- a/tests/test_posting_helpers.py +++ b/tests/test_posting_helpers.py @@ -19,6 +19,7 @@ from ocr_toolkit.posting import formatting as posting_formatting from ocr_toolkit.posting import gitlab, markers, payloads, result, settings, snapshot, workflow from ocr_toolkit.posting.markers import FINGERPRINT_LEN, build_marker +from ocr_toolkit.posting.suggestions import SuggestionDecision, SuggestionState from ocr_toolkit.result_contract import CoverageFailure, ReviewOutcome from tests.support import ( gitlab_config, @@ -419,6 +420,65 @@ def fake_fallback(*args: Any, **kwargs: Any) -> dict[str, int]: self.assertNotIn("fallback", calls) self.assertNotIn("delete-old", calls) + def test_post_results_retains_finding_but_omits_unproven_suggestion(self) -> None: + inline_bodies: list[str] = [] + + def capture_discussion(*args: Any, **kwargs: Any) -> gitlab.GitLabWriteResult: + inline_bodies.append(kwargs["body"]) + return gitlab.GitLabWriteResult("posted") + + with ( + patched_attr( + workflow, + "get_diff_refs", + lambda _config: {"base_sha": "a", "start_sha": "b", "head_sha": "c"}, + ), + patched_attr( + workflow, + "head_file_text", + lambda *_args: "route:\n destination: 192.0.2.0/24\n", + ), + patched_attr( + workflow, + "collect_previous_bot_comment_refs", + lambda _config: snapshot.BotCommentRefs(), + ), + patched_attr(workflow, "post_review_discussion", capture_discussion), + patched_attr( + workflow, + "post_review_note_bounded", + lambda *_args: {"id": 1}, + ), + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr( + workflow, + "delete_previous_bot_comments_if_collected", + lambda *_args: None, + ), + redirect_stdout(io.StringIO()), + ): + exit_code = workflow.post_results( + gitlab_config(), + { + "comments": [ + { + "path": "config/service.yml", + "start_line": 1, + "end_line": 2, + "content": "Use the documentation network.", + "existing_code": "stale content", + "suggestion_code": "route:\n destination: 198.51.100.0/24", + } + ] + }, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(len(inline_bodies), 1) + self.assertIn("Use the documentation network.", inline_bodies[0]) + self.assertIn("did not match the reviewed range", inline_bodies[0]) + self.assertNotIn("```suggestion", inline_bodies[0]) + def test_invalid_inline_position_falls_back_without_rollback(self) -> None: calls: list[str] = [] @@ -832,7 +892,12 @@ def test_inline_comment_neutralizes_model_controlled_suggestion_fences(self) -> "content": "Prose\n```suggestion\nmalicious\n```", "suggestion_code": "safe()", "line": 10, - } + }, + suggestion_decision=SuggestionDecision( + SuggestionState.ACTIONABLE, + replacement="safe()", + range_suffix="-0+0", + ), ) self.assertIn("```text\nmalicious", body) @@ -1074,58 +1139,6 @@ class Result: ["git", "-c", "core.hooksPath=/dev/null", "cat-file", "-s"], ) - def test_noop_suggestion_matches_only_the_exact_bounded_head_range(self) -> None: - """Suppress transport-equivalent replacements without hiding the finding.""" - - calls: list[list[str]] = [] - - def fake_run(args: list[str], **_kwargs: Any) -> Any: - calls.append(args) - - class Result: - returncode = 0 - stdout = "17" if "-s" in args else b"before\r\ntarget\r\nafter\r\n" - - return Result() - - refs = {"head_sha": "a" * 40} - cache: workflow.FileTextCache = {} - with patched_attr(workflow.subprocess, "run", fake_run): - identical = workflow.suggestion_matches_head_range( - refs, - "src/example.py", - {"start_line": 2, "end_line": 2, "suggestion_code": "target\n"}, - cache, - ) - changed = workflow.suggestion_matches_head_range( - refs, - "src/example.py", - {"start_line": 2, "end_line": 2, "suggestion_code": "replacement"}, - cache, - ) - - self.assertTrue(identical) - self.assertFalse(changed) - self.assertEqual(len(calls), 2) - - def test_noop_suggestion_rejects_unsafe_paths_before_git(self) -> None: - """Do not turn an OCR-controlled path into Git revision syntax.""" - - calls: list[list[str]] = [] - with patched_attr( - workflow.subprocess, - "run", - lambda args, **_kwargs: calls.append(args), - ): - matches = workflow.suggestion_matches_head_range( - {"head_sha": "a" * 40}, - "../outside.py", - {"line": 1, "suggestion_code": "same"}, - ) - - self.assertFalse(matches) - self.assertEqual(calls, []) - def test_coverage_diagnostics_are_deduplicated_redacted_and_fail_closed(self) -> None: """Count unique files while keeping malformed failure paths out of public notes.""" diff --git a/tests/test_posting_suggestions.py b/tests/test_posting_suggestions.py new file mode 100644 index 0000000..16517e9 --- /dev/null +++ b/tests/test_posting_suggestions.py @@ -0,0 +1,149 @@ +"""Regression tests for proof-bound GitLab suggestion rendering.""" + +from __future__ import annotations + +import unittest +from typing import Any + +from ocr_toolkit.posting import formatting, suggestions + +SOURCE = "before\r\nroute:\r\n destination: 192.0.2.0/24\r\nafter\r\n" + + +def evaluate(**overrides: Any) -> suggestions.SuggestionDecision: + """Evaluate a synthetic replacement against one immutable source blob.""" + + comment: dict[str, Any] = { + "line": 2, + "start_line": 2, + "end_line": 3, + "existing_code": "route:\n destination: 192.0.2.0/24", + "suggestion_code": "route:\n destination: 198.51.100.0/24", + } + comment.update(overrides) + return suggestions.evaluate_suggestion(comment, "config/service.yml", lambda _path: SOURCE) + + +class SuggestionValidationTests(unittest.TestCase): + """Prove only one exact contiguous replacement becomes actionable.""" + + def test_valid_one_line_replacement_is_actionable(self) -> None: + decision = evaluate( + start_line=2, + end_line=2, + existing_code="route:\n", + suggestion_code="endpoint:\r\n", + ) + + self.assertEqual(decision.state, suggestions.SuggestionState.ACTIONABLE) + self.assertEqual(decision.replacement, "endpoint:") + self.assertEqual(decision.range_suffix, "-0+0") + + def test_valid_multiline_replacement_is_actionable(self) -> None: + decision = evaluate() + + self.assertEqual(decision.state, suggestions.SuggestionState.ACTIONABLE) + body = formatting.format_inline_comment( + {"content": "Use the documentation range."}, + suggestion_decision=decision, + ) + self.assertIn("```suggestion:-0+1", body) + self.assertIn("198.51.100.0/24", body) + + def test_missing_existing_code_is_omitted(self) -> None: + self.assertEqual( + evaluate(existing_code=None).omission, + suggestions.SuggestionOmission.MISSING_EXISTING_CODE, + ) + + def test_stale_existing_code_is_omitted(self) -> None: + self.assertEqual( + evaluate(existing_code="stale").omission, + suggestions.SuggestionOmission.EXISTING_CODE_MISMATCH, + ) + + def test_invalid_and_out_of_bounds_ranges_are_omitted(self) -> None: + self.assertEqual( + evaluate(start_line=3, end_line=2).omission, + suggestions.SuggestionOmission.INVALID_RANGE, + ) + self.assertEqual( + evaluate(start_line=2, end_line=20).omission, + suggestions.SuggestionOmission.RANGE_OUT_OF_BOUNDS, + ) + + def test_synthetic_omission_bridge_is_omitted(self) -> None: + for marker in ("...", "# ...", "// ...", "/* ... */", "", "(* ... *)"): + with self.subTest(marker=marker): + decision = evaluate( + suggestion_code=( + "route:\n destination: 198.51.100.0/24\n\n" + f"{marker}\naccess:\n allowed: true" + ) + ) + + self.assertEqual( + decision.omission, + suggestions.SuggestionOmission.SYNTHETIC_OMISSION, + ) + + def test_non_omission_ellipsis_remains_valid_code(self) -> None: + decision = evaluate( + suggestion_code=( + "route:\n destination: 198.51.100.0/24\ndescription: Continue ... with fallback" + ) + ) + + self.assertEqual(decision.state, suggestions.SuggestionState.ACTIONABLE) + + def test_diff_prefixed_replacement_is_omitted(self) -> None: + decision = evaluate(suggestion_code="+route:\n+ destination: 198.51.100.0/24") + + self.assertEqual(decision.omission, suggestions.SuggestionOmission.DIFF_PREFIXED) + + def test_exact_noop_is_suppressed_without_existing_code(self) -> None: + decision = evaluate( + existing_code=None, + suggestion_code="route:\r\n destination: 192.0.2.0/24\r\n", + ) + + self.assertEqual(decision.state, suggestions.SuggestionState.NO_OP) + self.assertEqual(formatting.format_suggestion_block(decision), "") + + def test_unsafe_path_is_rejected_before_blob_read(self) -> None: + calls: list[str] = [] + + def read_blob(path: str) -> str | None: + calls.append(path) + return "same\n" + + decision = suggestions.evaluate_suggestion( + {"line": 1, "existing_code": "same", "suggestion_code": "changed"}, + "../outside.py", + read_blob, + ) + + self.assertEqual(decision.omission, suggestions.SuggestionOmission.INVALID_PATH) + self.assertEqual(calls, []) + + def test_omission_keeps_finding_and_exposes_only_closed_reason(self) -> None: + decision = evaluate(existing_code="token=private-value") + body = formatting.format_inline_comment( + {"content": "The route should use the documentation network."}, + suggestion_decision=decision, + ) + + self.assertIn("The route should use the documentation network.", body) + self.assertIn("did not match the reviewed range", body) + self.assertNotIn("```suggestion", body) + self.assertNotIn("private-value", body) + + def test_impossible_typed_decision_is_rejected(self) -> None: + with self.assertRaises(ValueError): + suggestions.SuggestionDecision(suggestions.SuggestionState.ACTIONABLE) + with self.assertRaises(ValueError): + suggestions.SuggestionDecision(suggestions.SuggestionState.OMITTED) + + +if __name__ == "__main__": + unittest.main() From d36163794fb0309463c712180c88e69b0053b03c Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:26:33 +0200 Subject: [PATCH 3/7] Add exact-SHA GitLab automatic approval --- PLANS.md | 39 +- README.md | 6 + changelog.d/71.feature.md | 2 + docs/configuration.md | 17 +- docs/gitlab.md | 9 +- docs/operations.md | 39 +- docs/security.md | 10 + examples/gitlab/ocr-review.gitlab-ci.yml | 2 + src/ocr_toolkit/posting/approval.py | 139 +++++ src/ocr_toolkit/posting/formatting.py | 4 + src/ocr_toolkit/posting/gitlab.py | 37 +- src/ocr_toolkit/posting/gitlab_approval.py | 281 +++++++++ src/ocr_toolkit/posting/markers.py | 56 ++ src/ocr_toolkit/posting/settings.py | 55 +- src/ocr_toolkit/posting/snapshot.py | 24 + src/ocr_toolkit/posting/workflow.py | 251 ++++++-- tests/test_operations_docs.py | 28 + tests/test_posting_approval.py | 674 +++++++++++++++++++++ 18 files changed, 1621 insertions(+), 52 deletions(-) create mode 100644 changelog.d/71.feature.md create mode 100644 src/ocr_toolkit/posting/approval.py create mode 100644 src/ocr_toolkit/posting/gitlab_approval.py create mode 100644 tests/test_posting_approval.py diff --git a/PLANS.md b/PLANS.md index 489d625..de37e46 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,7 +4,7 @@ Use this file for active, blocked, or recently completed execution work. Update ## Active Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7 -Status: active; issue #70 checkpoint complete +Status: active; issues #70 and #71 checkpoints complete Owner: Codex Last Updated: 2026-08-10 Release Classification: release-required @@ -52,7 +52,7 @@ independently read back. 1. [x] Implement typed contiguous-range suggestion validation, immutable-head proof, bounded omission reasons, documentation, complete regressions, review, and the #70 checkpoint commit. -2. [ ] Implement typed auto-approval configuration and policy, exact-SHA GitLab +2. [x] Implement typed auto-approval configuration and policy, exact-SHA GitLab synchronization/write/readback, managed own-user approval receipts, documentation, complete regressions, review, and the #71 checkpoint commit. 3. [ ] Replace the redundant post-release closure-PR contract with exact-tree @@ -118,6 +118,41 @@ independently read back. paths, and workflow-level proof that only the apply fence is withheld. Towncrier 0.4.7 draft and `git diff --check` pass. +### Issue #71 Checkpoint + +- `OCR_AUTO_APPROVE` is a typed default-on setting using the shared + true/false, 1/0, yes/no, and on/off vocabulary. Invalid values fail closed to + disabled without logging their contents. The fixed policy consumes the full + unsuppressed OCR finding set and requires a complete manifest, zero warnings, + failures, waivers, budget stop, or omitted findings, no more than three exact + `low` findings, and only style/documentation/maintainability categories. +- Approval is a distinct post-publication transaction. The GitLab adapter reads + bounded MR and full paginated diff-version state, selects the highest valid + version ID, waits at most ten two-second intervals for merge/approval + synchronization and a non-null patch ID, verifies the open current head, and + submits only the reviewed 40-hex SHA. Approve, unapprove, and summary-update + writes are attempted once and followed by bounded readback. +- Versioned managed-approval receipts are accepted only from the fixed prefix of + an owned plain toolkit summary. Conflicting, forged fallback, malformed, or + wrong-user receipts cannot authorize unapproval. A later complete + authoritative ineligible review can remove only the authenticated user's + proven managed approval; partial, skipped, legacy, disabled, and ambiguous + states preserve it. No runtime path calls GitLab `reset_approvals`. +- The published summary contains one bounded approval state. Eligible runs first + publish a conservative failed-until-confirmed state, then update the uniquely + marked owned summary once after provider readback. Failure never rolls back the + advisory review; strict mode returns nonzero while advisory mode remains + nonfatal. Existing GitLab rules, groups, Code Owners, protected branches, and + reauthentication stay authoritative. +- Self-review fixed receipt loss on partial reviews, version-order assumptions, + receipt parsing after cross-endpoint deduplication, stale receipt inheritance, + different-SHA approval claims, and provisional-summary truth. Ruff and strict + mypy pass; 148 posting/approval/suggestion tests and 15 public + documentation/integration contracts pass. Towncrier 0.4.7 draft includes the + default-on write and opt-out, and `git diff --check` passes. Roadmap and future + backlog statuses remain unchanged because neither issue completes an existing + outcome milestone or activation trigger. + ## Completed Plan: Reconcile 0.4.6 lifecycle, architecture, and backlog truth Status: completed; validated documentation/process PR handoff diff --git a/README.md b/README.md index 78815f0..889c97f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ On a successful rerun, the toolkit replaces untouched OCR-only notes instead of Suppression uses both the GitLab diff position and a stable finding fingerprint, so ordinary line shifts do not normally bring the same bug back. A materially changed finding can still receive a new discussion. See [GitLab review operations](docs/operations.md) for the complete lifecycle, posting modes, permissions, failure behavior, and Mermaid state diagram. +After every current review note publishes, the GitLab adapter can add a +conservative approval bound to the exact reviewed source SHA. This write is +enabled by default; set `OCR_AUTO_APPROVE=false` before upgrading when the bot +must remain comment-only. GitLab approval rules and protected-branch policy +remain authoritative. + Project-wide accepted tradeoffs can be recorded separately in `.opencodereview/accepted-decisions.md`; the evidence collector supplies target-ref decisions to OCR and never lets a source change self-authorize its own review. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) for the entry format, inline marker convention, security boundary, and limitations. ## Project architecture diff --git a/changelog.d/71.feature.md b/changelog.d/71.feature.md new file mode 100644 index 0000000..b7bf274 --- /dev/null +++ b/changelog.d/71.feature.md @@ -0,0 +1,2 @@ +Add default-on `OCR_AUTO_APPROVE` for conservative, exact-SHA GitLab approval after every current review note publishes, with an explicit fail-closed opt-out and bounded status readback. +Limit eligibility to complete manifest-backed reviews with at most three low-severity style, documentation, or maintainability findings, and remove only a proven toolkit-managed approval when a later complete review becomes ineligible. diff --git a/docs/configuration.md b/docs/configuration.md index b82b98d..59a82f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,10 +53,25 @@ Posting requires `GITLAB_API_TOKEN`, `CI_SERVER_URL`, `CI_PROJECT_ID`, and `CI_M ## Posting controls -`OCR_POST_MODE`, `OCR_STRICT_POSTING`, `OCR_EXIT_CODE`, `OCR_MAX_POST_COMMENTS`, `OCR_MAX_RESULT_BYTES`, `OCR_POST_ERROR_DETAILS`, and `OCR_POST_EMOJI` control write behavior and bounded error reporting. Invalid numeric or boolean values fail closed or fall back to conservative defaults as documented in command output. Human replies to bot-created discussions prevent automated ownership actions on that discussion. +`OCR_POST_MODE`, `OCR_STRICT_POSTING`, `OCR_EXIT_CODE`, `OCR_MAX_POST_COMMENTS`, `OCR_MAX_RESULT_BYTES`, `OCR_POST_ERROR_DETAILS`, `OCR_POST_EMOJI`, and `OCR_AUTO_APPROVE` control write behavior and bounded error reporting. Human replies to bot-created discussions prevent automated ownership actions on that discussion. `OCR_POST_EMOJI` defaults to `true`. Set it to `false`, `0`, `no`, or `off` to disable every emoji added by the toolkit to GitLab review-health and aggregate severity/category summaries. Inline severity/category fields remain text-only in both modes. This does not rewrite emoji already contained in upstream OCR finding text. +`OCR_AUTO_APPROVE` defaults to `true` and adds a formal GitLab approval after a +complete review publishes. It accepts `true`, `1`, `yes`, or `on`; set `false`, +`0`, `no`, or `off` to disable all approval and unapproval management for that +run. An empty value uses the enabled default. Any other value fails closed to +disabled and emits a bounded diagnostic without printing the value. Disabled +runs preserve an earlier toolkit-managed approval. + +The initial policy is fixed: zero findings, or at most three findings whose +severity is exactly `low` and category is exactly `style`, `documentation`, or +`maintainability`, are eligible. Missing, unknown, differently cased, or +non-string metadata blocks approval, as do warnings, failed or waived coverage, +partial/budget outcomes, legacy results without a supported coverage manifest, +and findings omitted by `OCR_MAX_POST_COMMENTS`. There are intentionally no +environment variables for policy thresholds or category lists in this release. + `ocr-ci review --result PATH --stderr PATH -- ...` executes OCR without posting, creates private artifacts, and prints a bounded redacted stderr excerpt to the CI log when OCR fails. It accepts only a regular, single-link result artifact and, after a successful OCR process, atomically replaces that artifact with an owner-only copy containing the toolkit's bounded MCP-use receipt. `OCR_POST_ERROR_DETAILS=1` separately opts into including the same safe stderr excerpt in the GitLab failure note; leave it unset when diagnostics should remain runner-only. ## Repository evidence diff --git a/docs/gitlab.md b/docs/gitlab.md index c76e2fc..8afc8d0 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -10,7 +10,7 @@ Copy and adapt [the synthetic CI example](../examples/gitlab/ocr-review.gitlab-c ## Required secrets -- `GITLAB_API_TOKEN`: a dedicated bot token with only the project/API permissions required to read the merge request and create/update its comments. +- `GITLAB_API_TOKEN`: a dedicated bot token with only the project/API permissions required to read the merge request, create/update its comments, and approve when `OCR_AUTO_APPROVE` is enabled. - `OCR_LLM_TOKEN`: the LLM gateway credential used by OCR. - `OCR_SHA256`: the trusted checksum for the pinned OCR binary asset. @@ -22,6 +22,13 @@ Store secrets as masked, protected CI variables. Do not place them in YAML, comm `ocr-ci preflight` validates the installed OCR version, GitLab access, and configured LLM model. `configure` resolves `OCR_REVIEW_LANGUAGE`. `ocr-ci review` owns evidence collection, private artifacts, compact bootstrap, and the complete MCP registry: the mandatory `ocr_toolkit_evidence` server and every optional configured MCP are independent entries. After OCR succeeds, `review` validates mandatory evidence use and atomically binds a safe schema-versioned per-server MCP-use receipt to the private result. `post` reads that review-time receipt instead of reconstructing configuration, then publishes bounded notes with rollback and ownership safeguards. +`ocr-ci post` also manages conservative automatic approval by default. After all +current notes publish, it waits for GitLab diff and approval synchronization, +verifies the current MR head against the reviewed SHA, submits that exact SHA, +and confirms only the authenticated toolkit user's approval through bounded +readback. Set `OCR_AUTO_APPROVE=false` for a comment-only bot or before upgrading +an integration whose approval rules have not granted the bot permission. + Repeated reviews have a reviewer-controlled lifecycle rather than appending the same notes indefinitely. Untouched OCR-only notes are replaced after a successful run, human-touched discussions are preserved, and `/ocr suppress` or `/ocr resolve` controls future matching findings. Read [GitLab review operations](operations.md) for the complete state machine, deduplication boundaries, posting modes, permissions, limits, and failure semantics. For a deliberate project-wide tradeoff that should be supplied to every review, add a narrowly scoped entry to `.opencodereview/accepted-decisions.md` in an earlier reviewed merge request. The [configuration reference](configuration.md#accepted-project-decisions) documents its `ocr-accept` marker convention, prompt-level semantics, and self-whitelisting guard. diff --git a/docs/operations.md b/docs/operations.md index 3a889b0..deff003 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -25,6 +25,41 @@ suppressed. The outcome wording distinguishes skipped, complete, complete-with-warnings, incomplete, token-budget, and failed reviews independently from whether findings were published. OCR 1.8.5 and later manifest failures provide the canonical failed-file receipt; legacy warnings are a bounded fallback, and `summary.files_reviewed` is never treated as proof of successful coverage. Zero-valued counters and configured-but-unused MCP servers are omitted. Status and aggregate semantic-category emoji are enabled by default and can be disabled together with `OCR_POST_EMOJI=false`; inline findings retain quiet text-only severity and category fields. +## Automatic approval lifecycle + +`OCR_AUTO_APPROVE=true` is the default. Approval is a separate transaction only +after every current review note publishes. A review is eligible only with a +supported complete manifest, no warnings, failures, waivers, token-budget stop, +or omitted findings, and at most three findings. Every finding must have +severity exactly `low` and category exactly `style`, `documentation`, or +`maintainability`. A complete zero-finding review is eligible. Four findings, +malformed metadata, or any other severity/category are not eligible. + +Before writing, the toolkit repeatedly reads the MR and its bounded diff-version +list. It requires an open MR, a current head equal to the reviewed 40-character +SHA, `detailed_merge_status` outside `checking` and `approvals_syncing`, and a +non-null `patch_id_sha`. It then passes that exact SHA to GitLab's approve API +and confirms the authenticated user in approval readback. A moved head is a +normal `skipped` result and is never retried against the new commit. + +Approve, unapprove, and summary-update writes are not retried after timeout, +connection loss, 5xx, or another ambiguous response. GitLab remains +authoritative for eligible approvers, required groups, Code Owners, +protected-branch rules, and password or SAML reauthentication. A rejected or +failed approval never rolls back the already published advisory review. + +The summary records exactly one bounded state: `approved`, `not eligible`, +`disabled`, `skipped`, or `failed`. With advisory `OCR_STRICT_POSTING=false`, an +approval-management failure leaves the published review successful but visibly +failed; with `OCR_STRICT_POSTING=true`, it also returns a nonzero exit code. + +When a later complete authoritative review is no longer eligible, the toolkit +may unapprove only the authenticated user and only when an owned versioned +summary receipt proves that this toolkit managed the earlier approval. It never +calls `reset_approvals` or removes a human approval. Partial, skipped, legacy, +and disabled runs preserve an earlier managed approval. Human discussion replies +remain ownership boundaries for notes but do not independently block approval. + ## Discussion lifecycle ```mermaid @@ -77,7 +112,7 @@ Suppression checks both the recorded inline position and compatible fingerprints `OCR_POST_MODE=direct` writes notes immediately. It exists as an emergency compatibility override. The toolkit still performs best-effort rollback, but an ambiguous network timeout can mean GitLab accepted a write that the runner cannot confirm. Prefer `draft` for normal CI. -`OCR_STRICT_POSTING=false` is the advisory default: an OCR or posting error remains visible in the job log and, when possible, in an MR note, but the posting helper exits successfully. Set `OCR_STRICT_POSTING=true` when OCR review is a required merge gate so OCR failures, an unavailable GitLab API, an unsafe previous-state snapshot, an invalid OCR result, or failed publication make the job fail. +`OCR_STRICT_POSTING=false` is the advisory default: an OCR, posting, or approval-management error remains visible in the job log and, when possible, in an MR note, but the posting helper exits successfully. Set `OCR_STRICT_POSTING=true` when OCR review is a required merge gate so OCR failures, an unavailable GitLab API, an unsafe previous-state snapshot, an invalid OCR result, failed publication, or failed approval management make the job fail. ## OCR diagnostics @@ -85,7 +120,7 @@ Run OCR through `ocr-ci review --result PATH --stderr PATH -- ...`. This wrapper ## GitLab identity and permissions -Use a dedicated project access token with `api` scope and at least the Developer role. Store it in `GITLAB_API_TOKEN`. The toolkit needs to read merge-request notes, discussions, diff refs, and the current token identity; create and delete its own notes or drafts; publish drafts; and resolve discussions requested by reviewers. +Use a dedicated project access token with `api` scope and at least the Developer role. Store it in `GITLAB_API_TOKEN`. The toolkit needs to read merge-request notes, discussions, diff refs, approval state, and the current token identity; create and delete its own notes or drafts; publish drafts; resolve discussions requested by reviewers; and, unless opted out, approve as that dedicated identity. GitLab must separately consider the identity eligible under the project's approval rules. The toolkit calls `GET /user` before posting and refuses to write if it cannot identify the token owner. It treats a note as bot-owned only when both the invisible OCR marker and the actual GitLab author ID match. Text that merely imitates an OCR marker is not enough to claim or delete another user's note. diff --git a/docs/security.md b/docs/security.md index adad0d6..124d11c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -15,6 +15,10 @@ The toolkit bridges four trust domains: repository content, OCR and its LLM/MCP - OCR result and provider response reads have byte limits. - GitLab notes enforce both UTF-8 byte limits and Python character limits. - Non-idempotent API writes are not blindly retried. +- Automatic approval is bound to the exact reviewed MR head after GitLab diff + synchronization and bounded readback. Unapproval is limited to the current + toolkit user and requires an owned versioned receipt; human approvals are + never reset or removed. - Markers, fingerprints, snapshots, and rollback logic constrain repeated runs. - Human replies are ownership boundaries: automation must not rewrite or resolve a discussion after a human takes part. - Merge-request source SHA and merge-result SHA remain distinct. @@ -27,6 +31,12 @@ Ansible Galaxy requirement includes use the same immutable-object boundary. Rela Use a dedicated bot identity and least-privilege `GITLAB_API_TOKEN`. Protect and mask credentials. Do not expose secrets to pipelines for untrusted forks. Begin with manual execution for trusted contributors, review generated notes, and enable automatic posting only after the repository's threat model is accepted. +Toolkit 0.4.7 adds formal GitLab approval as a default-on write. Set +`OCR_AUTO_APPROVE=false` before upgrading if the bot must remain comment-only or +is not an eligible project approver. GitLab approval rules, Code Owners, +protected branches, and reauthentication remain server-side controls; the +toolkit does not bypass them. + Pin Open Code Review `v1.8.10` and verify its checksum. Pin Python dependencies through `uv.lock` and GitHub Actions by immutable commit SHA. MCP stdio commands and remote endpoints are privileged configuration; allow only reviewed servers and tools. The [OCR compatibility policy](compatibility.md) requires double-source asset digest verification, bounded downloads, an executed Linux contract probe, and protected PR/release gates; qualification automation never writes directly to `main` or promotes an ambiguous release. diff --git a/examples/gitlab/ocr-review.gitlab-ci.yml b/examples/gitlab/ocr-review.gitlab-ci.yml index 4f5cf33..f94025c 100644 --- a/examples/gitlab/ocr-review.gitlab-ci.yml +++ b/examples/gitlab/ocr-review.gitlab-ci.yml @@ -12,6 +12,8 @@ variables: OCR_SHA256: "7161500791b8d27906ee8a29bf4429953b27048e90e33dd9a4ff6118932c9001" OCR_POST_MODE: "draft" OCR_STRICT_POSTING: "true" + # Default-on exact-SHA approval; set "false" for a comment-only bot. + OCR_AUTO_APPROVE: "true" OCR_LLM_VALIDATE_MODEL: "false" OCR_LLM_ALLOWED_MODELS: "" OCR_RUN_HELPER_TESTS: "false" diff --git a/src/ocr_toolkit/posting/approval.py b/src/ocr_toolkit/posting/approval.py new file mode 100644 index 0000000..4b2c733 --- /dev/null +++ b/src/ocr_toolkit/posting/approval.py @@ -0,0 +1,139 @@ +"""Conservative policy and typed outcomes for GitLab automatic approval.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from ocr_toolkit.posting.settings import BooleanSetting +from ocr_toolkit.result_contract import ReviewOutcome + +ALLOWED_CATEGORIES = frozenset({"style", "documentation", "maintainability"}) +MAX_APPROVABLE_FINDINGS = 3 + + +class ApprovalStatus(str, Enum): + """Closed public states for one automatic-approval transaction.""" + + APPROVED = "approved" + NOT_ELIGIBLE = "not eligible" + DISABLED = "disabled" + SKIPPED = "skipped" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class ApprovalResult: + """Bounded status rendered in the review summary and runner log.""" + + status: ApprovalStatus + reason: str + managed: bool = False + + +@dataclass(frozen=True, slots=True) +class ApprovalEligibility: + """Policy conclusion before provider state is consulted.""" + + eligible: bool + may_unapprove: bool + result: ApprovalResult + + +def evaluate_approval_policy( + setting: BooleanSetting, + outcome: ReviewOutcome, + comments: list[dict[str, Any]], + warnings: list[Any], + omitted_count: int, +) -> ApprovalEligibility: + """Evaluate the fixed v0.4.7 policy from authoritative OCR data.""" + + if not setting.enabled: + reason = ( + "configuration was invalid and failed closed" + if not setting.valid + else "disabled by OCR_AUTO_APPROVE" + ) + return ApprovalEligibility( + False, + False, + ApprovalResult(ApprovalStatus.DISABLED, reason), + ) + authoritative = bool( + outcome.manifest_present + and outcome.kind == "clean" + and not outcome.budget_exceeded + and not outcome.failed_count + and not outcome.waived_count + ) + if not outcome.manifest_present: + reason = "the OCR result has no authoritative coverage manifest" + elif outcome.kind != "clean" or outcome.budget_exceeded: + reason = "the OCR review did not complete cleanly" + elif outcome.failed_count or outcome.waived_count: + reason = "coverage contained failed or waived items" + elif warnings: + reason = "the OCR review reported warnings" + elif omitted_count: + reason = "one or more findings were omitted from publication" + elif len(comments) > MAX_APPROVABLE_FINDINGS: + reason = f"the review reported more than {MAX_APPROVABLE_FINDINGS} findings" + else: + reason = "" + + if reason: + return ApprovalEligibility( + False, + authoritative, + ApprovalResult(ApprovalStatus.NOT_ELIGIBLE, reason), + ) + + for comment in comments: + severity = comment.get("severity") + category = comment.get("category") + if severity != "low": + return ApprovalEligibility( + False, + True, + ApprovalResult( + ApprovalStatus.NOT_ELIGIBLE, + "a finding had a blocking or malformed severity", + ), + ) + if category not in ALLOWED_CATEGORIES: + return ApprovalEligibility( + False, + True, + ApprovalResult( + ApprovalStatus.NOT_ELIGIBLE, + "a finding had a blocking or malformed category", + ), + ) + + return ApprovalEligibility( + True, + False, + ApprovalResult( + ApprovalStatus.SKIPPED, + "awaiting post-publication SHA verification", + ), + ) + + +def approval_summary_line(result: ApprovalResult) -> str: + """Render exactly one bounded automatic-approval state line.""" + + return f"- Automatic approval: `{result.status.value}` — {result.reason}." + + +def provisional_approval_result(eligibility: ApprovalEligibility) -> ApprovalResult: + """Return a fail-closed state safe to publish before provider readback.""" + + if not eligibility.eligible: + return eligibility.result + return ApprovalResult( + ApprovalStatus.FAILED, + "automatic approval has not yet been confirmed", + ) diff --git a/src/ocr_toolkit/posting/formatting.py b/src/ocr_toolkit/posting/formatting.py index 5eeb139..a3dbfb1 100644 --- a/src/ocr_toolkit/posting/formatting.py +++ b/src/ocr_toolkit/posting/formatting.py @@ -17,6 +17,7 @@ ) from ocr_toolkit.common.redaction import redact_sensitive from ocr_toolkit.ocr_result import TOOLKIT_RESULT_SCHEMA_VERSION +from ocr_toolkit.posting.approval import ApprovalResult, approval_summary_line from ocr_toolkit.posting.comments import ( clean_text, code_text, @@ -775,6 +776,7 @@ def summarize_result( coverage_diagnostics: CoverageDiagnostics | None = None, warnings: Sequence[Any] = (), suppressed_count: int = 0, + approval_result: ApprovalResult | None = None, emoji: bool | None = None, ) -> str: """Build one decision-first summary for every validated OCR outcome.""" @@ -891,6 +893,8 @@ def summarize_result( technical.append( f"- Posting: {inline_count} inline, {fallback_count} fallback, {omitted_count} omitted" ) + if approval_result is not None: + technical.append(approval_summary_line(approval_result)) if suppressed_count: technical.append(f"- Reviewer suppression: {suppressed_count}") if coverage_summary: diff --git a/src/ocr_toolkit/posting/gitlab.py b/src/ocr_toolkit/posting/gitlab.py index bcf07d4..a183185 100644 --- a/src/ocr_toolkit/posting/gitlab.py +++ b/src/ocr_toolkit/posting/gitlab.py @@ -58,6 +58,7 @@ class GitLabWriteResult: status: str response: Any | None = None + http_status: int | None = None @property def posted(self) -> bool: @@ -282,7 +283,7 @@ def api_write_url_detailed( print(f"GitLab API error {exc.code} for {method} {url}: {safe_body}", file=sys.stderr) if _is_invalid_position_error(exc.code, raw_body): return GitLabWriteResult("invalid_position") - return GitLabWriteResult("write_failed") + return GitLabWriteResult("write_failed", http_status=exc.code) except GitLabResponseTooLarge as exc: print(f"GitLab API response too large for {method} {url}: {exc}", file=sys.stderr) return GitLabWriteResult("write_failed") @@ -515,6 +516,40 @@ def delete_plain_note(config: GitLabConfig, note_id: int) -> bool: return response is not None +def update_plain_note(config: GitLabConfig, note_id: int, body: str) -> GitLabWriteResult: + """Update one known toolkit-owned summary without retrying the write.""" + + return api_write_url_detailed( + url=f"{config.api_base}/notes/{note_id}", + api_token=config.api_token, + auth_header=config.auth_header, + data={"body": build_marked_note_body(body)}, + method="PUT", + ) + + +def approve_merge_request(config: GitLabConfig, sha: str) -> GitLabWriteResult: + """Approve exactly one merge-request head without retrying the write.""" + + return api_write_url_detailed( + url=f"{config.api_base}/approve", + api_token=config.api_token, + auth_header=config.auth_header, + data={"sha": sha}, + ) + + +def unapprove_merge_request(config: GitLabConfig) -> GitLabWriteResult: + """Remove only the authenticated user's approval without retrying.""" + + return api_write_url_detailed( + url=f"{config.api_base}/unapprove", + api_token=config.api_token, + auth_header=config.auth_header, + data={}, + ) + + def delete_discussion_note(config: GitLabConfig, discussion_id: str, note_id: int) -> bool: """Delete a note inside a merge request discussion thread.""" diff --git a/src/ocr_toolkit/posting/gitlab_approval.py b/src/ocr_toolkit/posting/gitlab_approval.py new file mode 100644 index 0000000..89b0466 --- /dev/null +++ b/src/ocr_toolkit/posting/gitlab_approval.py @@ -0,0 +1,281 @@ +"""GitLab adapter for exact-SHA automatic approval management.""" + +from __future__ import annotations + +import re +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from ocr_toolkit.posting import gitlab +from ocr_toolkit.posting.approval import ( + ApprovalEligibility, + ApprovalResult, + ApprovalStatus, +) +from ocr_toolkit.posting.markers import ManagedApprovalReceipt + +SYNC_ATTEMPTS = 10 +SYNC_INTERVAL_SECONDS = 2.0 +PENDING_MERGE_STATUSES = frozenset({"checking", "approvals_syncing"}) + + +@dataclass(frozen=True, slots=True) +class ApprovalExecution: + """Final provider result and any ownership receipt that must survive.""" + + result: ApprovalResult + receipt: ManagedApprovalReceipt | None = None + + +@dataclass(frozen=True, slots=True) +class GitLabApprovalState: + """One synchronized current-head and current-user approval snapshot.""" + + own_approved: bool + + +def _full_sha(value: Any) -> str: + """Return a full Git object ID or an empty string for malformed input.""" + + if not isinstance(value, str): + return "" + return value if re.fullmatch(r"[0-9a-f]{40}", value) else "" + + +def _current_user_approved(payload: Any, current_user_id: int) -> bool | None: + """Read only the authenticated user's approval from the bounded API result.""" + + if not isinstance(payload, dict): + return None + approved_by = payload.get("approved_by") + if not isinstance(approved_by, list): + return None + own_approved = False + for item in approved_by: + if not isinstance(item, dict): + return None + user = item.get("user") + if not isinstance(user, dict): + return None + user_id = user.get("id") + if isinstance(user_id, bool) or not isinstance(user_id, int): + return None + own_approved = own_approved or user_id == current_user_id + return own_approved + + +def _latest_diff_version(config: gitlab.GitLabConfig) -> dict[str, Any] | None: + """Return the highest validated GitLab diff-version id from bounded pages.""" + + versions = gitlab.api_get_paginated(config, "/versions", max_pages=20) + if not isinstance(versions, list) or not versions: + return None + candidates: list[tuple[int, dict[str, Any]]] = [] + for version in versions: + if not isinstance(version, dict): + return None + version_id = version.get("id") + if isinstance(version_id, bool) or not isinstance(version_id, int) or version_id <= 0: + return None + candidates.append((version_id, version)) + return max(candidates, key=lambda item: item[0])[1] + + +def wait_for_synchronized_approval_state( + config: gitlab.GitLabConfig, + expected_sha: str, + *, + attempts: int = SYNC_ATTEMPTS, + interval_seconds: float = SYNC_INTERVAL_SECONDS, + sleep: Callable[[float], None] = time.sleep, +) -> tuple[GitLabApprovalState | None, ApprovalResult | None]: + """Wait for GitLab diff/approval synchronization and verify the exact head.""" + + if config.current_user_id is None or not _full_sha(expected_sha): + return None, ApprovalResult( + ApprovalStatus.SKIPPED, + "the reviewed commit or toolkit user could not be verified", + ) + + for attempt in range(attempts): + merge_request = gitlab.api_request(config, "", method="GET") + latest = _latest_diff_version(config) + if not isinstance(merge_request, dict) or latest is None: + return None, ApprovalResult( + ApprovalStatus.FAILED, + "GitLab synchronization state was unavailable", + ) + + mr_sha = _full_sha(merge_request.get("sha")) + diff_sha = _full_sha(latest.get("head_commit_sha")) + if not mr_sha or not diff_sha: + return None, ApprovalResult( + ApprovalStatus.FAILED, + "GitLab returned malformed merge-request head metadata", + ) + if mr_sha != expected_sha or diff_sha != expected_sha: + return None, ApprovalResult( + ApprovalStatus.SKIPPED, + "the merge-request head no longer matches the reviewed commit", + ) + if merge_request.get("state") != "opened": + return None, ApprovalResult( + ApprovalStatus.SKIPPED, + "the merge request is not open", + ) + + detailed_status = merge_request.get("detailed_merge_status") + patch_id = _full_sha(latest.get("patch_id_sha")) + if not isinstance(detailed_status, str) or not detailed_status: + return None, ApprovalResult( + ApprovalStatus.FAILED, + "GitLab returned malformed merge synchronization metadata", + ) + if detailed_status not in PENDING_MERGE_STATUSES and patch_id: + approvals = gitlab.api_request(config, "/approvals", method="GET") + own_approved = _current_user_approved(approvals, config.current_user_id) + if own_approved is None: + return None, ApprovalResult( + ApprovalStatus.FAILED, + "GitLab returned malformed approval state", + ) + return GitLabApprovalState(own_approved), None + + if attempt + 1 < attempts: + sleep(interval_seconds) + + return None, ApprovalResult( + ApprovalStatus.SKIPPED, + "GitLab did not finish diff and approval synchronization in time", + ) + + +def _write_rejection(result: gitlab.GitLabWriteResult, action: str) -> ApprovalResult: + """Classify a non-retried provider write without exposing its response body.""" + + if result.http_status == 409: + return ApprovalResult( + ApprovalStatus.SKIPPED, + "GitLab rejected a stale merge-request head", + ) + if result.http_status is not None and 400 <= result.http_status < 500: + return ApprovalResult( + ApprovalStatus.SKIPPED, + f"GitLab did not permit the toolkit user to {action}", + ) + return ApprovalResult( + ApprovalStatus.FAILED, + f"the GitLab {action} result was not safely confirmed", + ) + + +def execute_approval( + config: gitlab.GitLabConfig, + eligibility: ApprovalEligibility, + expected_sha: str, + prior_receipt: ManagedApprovalReceipt | None, + *, + attempts: int = SYNC_ATTEMPTS, + interval_seconds: float = SYNC_INTERVAL_SECONDS, + sleep: Callable[[float], None] = time.sleep, +) -> ApprovalExecution: + """Apply the policy after publication while preserving human approvals.""" + + if prior_receipt is not None and prior_receipt.user_id != config.current_user_id: + prior_receipt = None + if eligibility.result.status is ApprovalStatus.DISABLED: + return ApprovalExecution(eligibility.result, prior_receipt) + if not eligibility.eligible and (prior_receipt is None or not eligibility.may_unapprove): + return ApprovalExecution(eligibility.result, prior_receipt) + + state, synchronization_result = wait_for_synchronized_approval_state( + config, + expected_sha, + attempts=attempts, + interval_seconds=interval_seconds, + sleep=sleep, + ) + if state is None: + return ApprovalExecution( + synchronization_result + or ApprovalResult(ApprovalStatus.FAILED, "GitLab synchronization failed"), + prior_receipt, + ) + + if eligibility.eligible: + if state.own_approved: + if prior_receipt is None: + return ApprovalExecution( + ApprovalResult( + ApprovalStatus.SKIPPED, + "the toolkit user already approved without a managed receipt", + ) + ) + if prior_receipt.reviewed_sha != expected_sha: + return ApprovalExecution( + ApprovalResult( + ApprovalStatus.SKIPPED, + "the existing managed approval was not bound to the reviewed commit", + ), + prior_receipt, + ) + return ApprovalExecution( + ApprovalResult( + ApprovalStatus.APPROVED, + "the toolkit user's approval is confirmed for the reviewed commit", + managed=True, + ), + ManagedApprovalReceipt(config.current_user_id or 0, expected_sha), + ) + + write = gitlab.approve_merge_request(config, expected_sha) + if not write.posted: + return ApprovalExecution(_write_rejection(write, "approve")) + confirmed, confirmation_error = wait_for_synchronized_approval_state( + config, + expected_sha, + attempts=1, + interval_seconds=0, + sleep=sleep, + ) + if confirmed is None or not confirmed.own_approved: + return ApprovalExecution( + confirmation_error + or ApprovalResult( + ApprovalStatus.FAILED, + "GitLab approval readback did not confirm the toolkit user", + ), + ) + return ApprovalExecution( + ApprovalResult( + ApprovalStatus.APPROVED, + "GitLab confirmed the toolkit user's exact-SHA approval", + managed=True, + ), + ManagedApprovalReceipt(config.current_user_id or 0, expected_sha), + ) + + if not state.own_approved: + return ApprovalExecution(eligibility.result) + write = gitlab.unapprove_merge_request(config) + if not write.posted: + return ApprovalExecution(_write_rejection(write, "unapprove"), prior_receipt) + confirmed, confirmation_error = wait_for_synchronized_approval_state( + config, + expected_sha, + attempts=1, + interval_seconds=0, + sleep=sleep, + ) + if confirmed is None or confirmed.own_approved: + return ApprovalExecution( + confirmation_error + or ApprovalResult( + ApprovalStatus.FAILED, + "GitLab unapproval readback did not confirm removal", + ), + prior_receipt, + ) + return ApprovalExecution(eligibility.result) diff --git a/src/ocr_toolkit/posting/markers.py b/src/ocr_toolkit/posting/markers.py index 1e54ea1..5743466 100644 --- a/src/ocr_toolkit/posting/markers.py +++ b/src/ocr_toolkit/posting/markers.py @@ -4,6 +4,7 @@ import hashlib import re +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from ocr_toolkit.posting.comments import clean_text, code_text, comment_line, line_number @@ -14,6 +15,24 @@ MARKER = "" +SUMMARY_RUN_MARKER_RE = re.compile(r"") + + +MANAGED_APPROVAL_RE = re.compile( + r"" +) + + +MANAGED_APPROVAL_SUMMARY_RE = re.compile( + r"\A\r?\n" + r"\r?\n" + r"\r?\n" + r"## Open Code Review(?:\r?\n|\Z)" +) + + MARKER_WITH_FINGERPRINT_RE = re.compile( r"^" ) @@ -27,6 +46,43 @@ FINGERPRINT_LEN = 32 # hex characters (= 16 raw bytes from blake2b) +@dataclass(frozen=True, slots=True) +class ManagedApprovalReceipt: + """Proof that this toolkit user managed approval for one reviewed SHA.""" + + user_id: int + reviewed_sha: str + + +def build_summary_run_marker(run_id: str) -> str: + """Render a unique bounded marker used to find the current summary note.""" + + if not re.fullmatch(r"[0-9a-f]{32}", run_id): + raise ValueError("summary run id must be 32 lowercase hexadecimal characters") + return f"" + + +def build_managed_approval_receipt(receipt: ManagedApprovalReceipt) -> str: + """Render a versioned marker proving toolkit-managed approval ownership.""" + + if receipt.user_id <= 0 or not re.fullmatch(r"[0-9a-f]{40}", receipt.reviewed_sha): + raise ValueError("managed approval receipt fields are invalid") + return ( + "" + ) + + +def managed_approval_receipt_from_body(body: str) -> ManagedApprovalReceipt | None: + """Parse one valid managed-approval receipt from an owned summary body.""" + + match = MANAGED_APPROVAL_SUMMARY_RE.match(body) + if match is None or len(MANAGED_APPROVAL_RE.findall(body)) != 1: + return None + raw_user_id, reviewed_sha = match.groups() + return ManagedApprovalReceipt(int(raw_user_id), reviewed_sha) + + def _digest_payload(parts: list[str], digest_size: int = FINGERPRINT_LEN // 2) -> str: """Return a blake2b digest for normalized fingerprint fields.""" diff --git a/src/ocr_toolkit/posting/settings.py b/src/ocr_toolkit/posting/settings.py index 2d71846..b8639bb 100644 --- a/src/ocr_toolkit/posting/settings.py +++ b/src/ocr_toolkit/posting/settings.py @@ -5,6 +5,7 @@ import os import re import sys +from dataclasses import dataclass from functools import cache from ocr_toolkit.ocr_result import ( # noqa: F401 -- retained import contract @@ -76,6 +77,41 @@ ) +TRUE_BOOLEAN_VALUES = frozenset({"1", "true", "yes", "on"}) +FALSE_BOOLEAN_VALUES = frozenset({"0", "false", "no", "off"}) + + +@dataclass(frozen=True, slots=True) +class BooleanSetting: + """One typed environment boolean and whether its source was valid.""" + + enabled: bool + valid: bool = True + + +def parse_boolean_setting( + name: str, + *, + default: bool, + invalid_default: bool, +) -> BooleanSetting: + """Parse the shared boolean vocabulary without logging the raw value.""" + + raw = getenv(name).strip().lower() + if not raw: + return BooleanSetting(default) + if raw in TRUE_BOOLEAN_VALUES: + return BooleanSetting(True) + if raw in FALSE_BOOLEAN_VALUES: + return BooleanSetting(False) + print( + f"{name} must be one of true/false, 1/0, yes/no, or on/off; " + f"using {'enabled' if invalid_default else 'disabled'}.", + file=sys.stderr, + ) + return BooleanSetting(invalid_default, valid=False) + + def getenv(name: str, default: str = "") -> str: """Return an environment variable or a default string.""" @@ -102,21 +138,20 @@ def post_mode() -> str: def post_emoji() -> bool: """Return whether toolkit-added status and finding emoji are enabled.""" - value = getenv("OCR_POST_EMOJI", "true").strip().lower() - if not value: - value = "true" - if value in {"1", "true", "yes", "on"}: - return True - if value in {"0", "false", "no", "off"}: - return False - print("OCR_POST_EMOJI must be boolean; using enabled by default.", file=sys.stderr) - return True + return parse_boolean_setting("OCR_POST_EMOJI", default=True, invalid_default=True).enabled + + +@cache +def auto_approve() -> BooleanSetting: + """Return fail-closed automatic approval configuration.""" + + return parse_boolean_setting("OCR_AUTO_APPROVE", default=True, invalid_default=False) def strict_posting() -> bool: """Return whether posting failures should make this script fail.""" - return getenv("OCR_STRICT_POSTING", "false").strip().lower() in {"1", "true", "yes"} + return parse_boolean_setting("OCR_STRICT_POSTING", default=False, invalid_default=False).enabled def max_post_comments() -> int: diff --git a/src/ocr_toolkit/posting/snapshot.py b/src/ocr_toolkit/posting/snapshot.py index 2514183..ebfeb53 100644 --- a/src/ocr_toolkit/posting/snapshot.py +++ b/src/ocr_toolkit/posting/snapshot.py @@ -18,12 +18,14 @@ ) from ocr_toolkit.posting.markers import ( OCR_REPLY_COMMAND_RE, + ManagedApprovalReceipt, author_id_from_note, comment_fingerprint, comment_fingerprint_candidates, fingerprint_from_marker, is_diff_note, is_own_bot_note, + managed_approval_receipt_from_body, ) from ocr_toolkit.posting.settings import post_mode, strict_posting @@ -50,6 +52,9 @@ class BotCommentRefs: suppressed_fingerprints: set[str] = field(default_factory=set) # Discussions that the bot should resolve after successful posting. discussions_to_resolve: list[str] = field(default_factory=list) + # Accepted only from an owned plain summary note. Ambiguous receipts are + # discarded so unapproval cannot be authorized by conflicting history. + managed_approval_receipt: ManagedApprovalReceipt | None = None def cleanup_drafts_created_by_this_run(config: GitLabConfig, draft_note_ids: list[int]) -> None: @@ -278,6 +283,7 @@ def collect_previous_bot_comment_refs( preserve_human_touched=preserve_human_touched, ) + managed_receipts: set[ManagedApprovalReceipt] = set() for note in plain_notes: if not isinstance(note, dict): continue @@ -289,6 +295,15 @@ def collect_previous_bot_comment_refs( if not isinstance(note_id, int): continue + # Parse ownership before de-duplicating `/notes` against + # `/discussions`: GitLab may expose the same general note through both + # collections even though the approval receipt belongs to the plain + # toolkit summary rather than an inline position. + if not is_diff_note(note): + receipt = managed_approval_receipt_from_body(str(note.get("body") or "")) + if receipt is not None and receipt.user_id == config.current_user_id: + managed_receipts.add(receipt) + # GET /notes can include diff notes that also appear in # /discussions. GitLab does not always expose enough shape in # /notes to classify them with is_diff_note(), so skip every own @@ -300,6 +315,15 @@ def collect_previous_bot_comment_refs( refs.all_plain_note_ids.append(note_id) refs.plain_note_ids.append(note_id) + if len(managed_receipts) == 1: + refs.managed_approval_receipt = managed_receipts.pop() + elif len(managed_receipts) > 1: + print( + "Multiple toolkit-managed approval receipts were found; " + "automatic unapproval is disabled for this run.", + file=sys.stderr, + ) + draft_notes: list[Any] = [] if post_mode() == "draft": fetched_draft_notes = api_get_paginated(config, "/draft_notes", max_pages=50) diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index ac3c4c1..93e7998 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -4,10 +4,11 @@ import os import re +import secrets import subprocess import sys from collections import Counter -from collections.abc import Sequence +from collections.abc import Callable, Sequence from pathlib import Path from typing import Any @@ -22,6 +23,13 @@ load_ocr_result, ) from ocr_toolkit.posting import gitlab as gitlab_api +from ocr_toolkit.posting.approval import ( + ApprovalEligibility, + ApprovalResult, + ApprovalStatus, + evaluate_approval_policy, + provisional_approval_result, +) from ocr_toolkit.posting.comments import ( clean_text, code_text, @@ -47,8 +55,17 @@ post_review_note_bounded, publish_created_draft_notes, resolve_discussion, + update_plain_note, +) +from ocr_toolkit.posting.gitlab_approval import ApprovalExecution, execute_approval +from ocr_toolkit.posting.markers import ( + ManagedApprovalReceipt, + annotate_comment_fingerprints, + build_managed_approval_receipt, + build_summary_run_marker, + is_own_bot_note, ) -from ocr_toolkit.posting.markers import annotate_comment_fingerprints +from ocr_toolkit.posting.payloads import build_marked_note_body from ocr_toolkit.posting.result import ( llm_billing_failure_warnings, normalize_coverage_diagnostics, @@ -74,6 +91,7 @@ # Kept as a module-level compatibility seam for tests and external monkey-patching. post_review_note = gitlab_api.post_review_note from ocr_toolkit.posting.settings import ( + auto_approve, max_post_comments, ocr_exit_code, post_emoji, @@ -116,6 +134,122 @@ def mr_head_sha() -> str: return clean_text(os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "")) +def summary_with_receipts( + body: str, + run_id: str, + receipt: ManagedApprovalReceipt | None, +) -> str: + """Attach bounded hidden identity and approval ownership to one summary.""" + + markers = [build_summary_run_marker(run_id)] + if receipt is not None: + markers.append(build_managed_approval_receipt(receipt)) + return "\n".join([*markers, body]) + + +def find_current_summary_note(config: GitLabConfig, run_id: str) -> int | None: + """Find exactly one owned published summary for this posting transaction.""" + + marker = build_summary_run_marker(run_id) + notes = gitlab_api.api_get_paginated( + config, + "/notes?sort=desc&order_by=created_at", + max_pages=50, + ) + if notes is None: + return None + matches: list[int] = [] + for note in notes: + if not isinstance(note, dict) or not is_own_bot_note(config, note, "body"): + continue + body = note.get("body") + note_id = note.get("id") + if isinstance(body, str) and marker in body and isinstance(note_id, int): + matches.append(note_id) + return matches[0] if len(matches) == 1 else None + + +def replace_current_summary( + config: GitLabConfig, + run_id: str, + body: str, +) -> bool: + """Update and read back one current summary without retrying the write.""" + + note_id = find_current_summary_note(config, run_id) + if note_id is None: + print( + "Cannot identify exactly one current OCR summary; " + "leaving the published advisory review unchanged.", + file=sys.stderr, + ) + return False + write = update_plain_note(config, note_id, body) + if not write.posted: + return False + readback = gitlab_api.api_request(config, f"/notes/{note_id}", method="GET") + return bool( + isinstance(readback, dict) + and is_own_bot_note(config, readback, "body") + and readback.get("body") == build_marked_note_body(body) + ) + + +def finalize_review_approval( + config: GitLabConfig, + previous_refs: BotCommentRefs, + outcome: ReviewOutcome, + draft_note_ids: list[int], + eligibility: ApprovalEligibility, + reviewed_commit: str, + run_id: str, + render_summary: Callable[[ApprovalResult], str], +) -> int: + """Publish notes, manage exact-SHA approval, update summary, then clean old state.""" + + if not finalize_posting(config, draft_note_ids): + return publish_failure_exit(config, draft_note_ids) + + execution = execute_approval( + config, + eligibility, + reviewed_commit, + previous_refs.managed_approval_receipt, + ) + final_body = summary_with_receipts( + render_summary(execution.result), + run_id, + execution.receipt, + ) + provisional_body = summary_with_receipts( + render_summary(provisional_approval_result(eligibility)), + run_id, + previous_refs.managed_approval_receipt, + ) + summary_updated = final_body == provisional_body or replace_current_summary( + config, + run_id, + final_body, + ) + if not summary_updated: + execution = ApprovalExecution( + ApprovalResult( + ApprovalStatus.FAILED, + "the published approval status could not be safely confirmed", + ), + execution.receipt or previous_refs.managed_approval_receipt, + ) + print( + "Automatic approval summary update failed after review publication.", + file=sys.stderr, + ) + + finalize_previous_review_state(config, previous_refs, outcome) + if execution.result.status is ApprovalStatus.FAILED and strict_posting(): + return 1 + return 0 + + DiffLineCache = dict[tuple[str, str, str], set[int]] FileLineCache = dict[tuple[str, str], list[tuple[int, str]]] ChangedPathCache = dict[tuple[str, str], list[str]] @@ -459,6 +593,10 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: return invalid_ocr_schema_exit(config, f"field 'comments[{index}]' must be an object") comments.append(comment) + # Approval policy consumes the complete authoritative OCR finding set, + # before reviewer suppression or the publication cap can hide a blocker. + approval_comments = list(comments) + warnings = warnings_value coverage_diagnostics = normalize_coverage_diagnostics(outcome, warnings) if outcome.kind == "failed": @@ -518,6 +656,16 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: comments = comments[:publish_limit] emoji = post_emoji() + approval_setting = auto_approve() + approval_eligibility = evaluate_approval_policy( + approval_setting, + outcome, + approval_comments, + warnings, + omitted_count, + ) + summary_run_id = secrets.token_hex(16) + reviewed_commit = reviewed_sha() reviewer_guide = format_reviewer_guide( comments, omitted_count, @@ -526,25 +674,36 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: ) if publishable_comment_count == 0: - body = summarize_result( - total=0, - inline_count=0, - fallback_count=0, - warning_count=len(warnings), - comments=(), - tool_calls_summary=tool_calls_summary, - mcp_usage_summary=mcp_usage_summary, - token_usage_summary=token_usage_summary, - reviewer_guide=reviewer_guide, - reviewed_sha=reviewed_sha(), - mr_head_sha=mr_head_sha(), - outcome_status="budget_exceeded" if outcome.budget_exceeded else outcome.kind, - outcome_message=outcome_message, - coverage_summary=outcome.coverage_summary, - coverage_diagnostics=coverage_diagnostics, - warnings=warnings, - suppressed_count=suppressed_count, - emoji=emoji, + + def render_no_comments_summary(approval_result: ApprovalResult) -> str: + """Render the no-findings summary with one approval state.""" + + return summarize_result( + total=0, + inline_count=0, + fallback_count=0, + warning_count=len(warnings), + comments=(), + tool_calls_summary=tool_calls_summary, + mcp_usage_summary=mcp_usage_summary, + token_usage_summary=token_usage_summary, + reviewer_guide=reviewer_guide, + reviewed_sha=reviewed_commit, + mr_head_sha=mr_head_sha(), + outcome_status=("budget_exceeded" if outcome.budget_exceeded else outcome.kind), + outcome_message=outcome_message, + coverage_summary=outcome.coverage_summary, + coverage_diagnostics=coverage_diagnostics, + warnings=warnings, + suppressed_count=suppressed_count, + approval_result=approval_result, + emoji=emoji, + ) + + body = summary_with_receipts( + render_no_comments_summary(provisional_approval_result(approval_eligibility)), + summary_run_id, + previous_bot_comment_refs.managed_approval_receipt, ) response = post_review_note_bounded( config, @@ -555,10 +714,16 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: if response is None: print("Failed to create OCR no-comments note.", file=sys.stderr) return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids) - if not finalize_posting(config, draft_note_ids): - return publish_failure_exit(config, draft_note_ids) - finalize_previous_review_state(config, previous_bot_comment_refs, outcome) - return 0 + return finalize_review_approval( + config, + previous_bot_comment_refs, + outcome, + draft_note_ids, + approval_eligibility, + reviewed_commit, + summary_run_id, + render_no_comments_summary, + ) refs = get_diff_refs(config) inline_count = 0 @@ -688,10 +853,10 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: print("Failed to create OCR omitted-comments note.", file=sys.stderr) return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids) - summary_response = post_review_note_bounded( - config, - "", - summarize_result( + def render_findings_summary(approval_result: ApprovalResult) -> str: + """Render the findings summary with one approval state.""" + + return summarize_result( total=len(comments), inline_count=inline_count, fallback_count=len(failed_comments), @@ -703,7 +868,7 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: token_usage_summary=token_usage_summary, reviewer_guide=reviewer_guide, fallback_reasons=fallback_reasons, - reviewed_sha=reviewed_sha(), + reviewed_sha=reviewed_commit, mr_head_sha=mr_head_sha(), outcome_status="budget_exceeded" if outcome.budget_exceeded else outcome.kind, outcome_message=outcome_message, @@ -711,7 +876,17 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: coverage_diagnostics=coverage_diagnostics, warnings=warnings, suppressed_count=suppressed_count, + approval_result=approval_result, emoji=emoji, + ) + + summary_response = post_review_note_bounded( + config, + "", + summary_with_receipts( + render_findings_summary(provisional_approval_result(approval_eligibility)), + summary_run_id, + previous_bot_comment_refs.managed_approval_receipt, ), draft_note_ids, ) @@ -720,17 +895,23 @@ def post_results(config: GitLabConfig, result: dict[str, Any]) -> int: print("Failed to create OCR summary note.", file=sys.stderr) return posting_failure_exit(config, previous_bot_comment_refs, draft_note_ids) - if not finalize_posting(config, draft_note_ids): - return publish_failure_exit(config, draft_note_ids) - - finalize_previous_review_state(config, previous_bot_comment_refs, outcome) + approval_exit = finalize_review_approval( + config, + previous_bot_comment_refs, + outcome, + draft_note_ids, + approval_eligibility, + reviewed_commit, + summary_run_id, + render_findings_summary, + ) print( f"Posted OCR comments: mode={post_mode()}, inline={inline_count}, " f"fallback={len(failed_comments)}, omitted={omitted_count}, " f"total={publishable_comment_count}" ) - return 0 + return approval_exit def finalize_previous_review_state( diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index 9fd5daf..259d3b8 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -54,10 +54,38 @@ def test_blocking_gitlab_example_uses_safe_posting_defaults() -> None: assert 'OCR_POST_MODE: "draft"' in example assert 'OCR_STRICT_POSTING: "true"' in example + assert 'OCR_AUTO_APPROVE: "true"' in example assert "OCR_POST_MODE=draft" in configuration assert "OCR_STRICT_POSTING=true" in configuration +def test_auto_approval_contract_is_default_on_exact_sha_and_own_user_only() -> None: + """Keep the new GitLab write and its safety boundaries explicit.""" + + operations = OPERATIONS.read_text(encoding="utf-8") + configuration = CONFIGURATION.read_text(encoding="utf-8") + example = GITLAB_EXAMPLE.read_text(encoding="utf-8") + + for phrase in ( + "`OCR_AUTO_APPROVE=true` is the default", + "at most three findings", + "severity exactly `low`", + "category exactly `style`, `documentation`, or\n`maintainability`", + "`patch_id_sha`", + "never retried against the new commit", + "never\ncalls `reset_approvals`", + "Partial, skipped, legacy,\nand disabled runs preserve", + ): + assert phrase in operations + + assert "`OCR_AUTO_APPROVE` defaults to `true`" in configuration + assert "`false`,\n`0`, `no`, or `off`" in configuration + assert ( + "There are intentionally no\nenvironment variables for policy thresholds" in configuration + ) + assert 'OCR_AUTO_APPROVE: "true"' in example + + def test_security_workflow_has_a_bounded_bandit_job() -> None: workflow = (PROJECT_ROOT / ".github" / "workflows" / "security.yml").read_text(encoding="utf-8") development = (PROJECT_ROOT / "docs" / "development.md").read_text(encoding="utf-8") diff --git a/tests/test_posting_approval.py b/tests/test_posting_approval.py new file mode 100644 index 0000000..aee8f69 --- /dev/null +++ b/tests/test_posting_approval.py @@ -0,0 +1,674 @@ +"""Policy and provider regressions for SHA-bound GitLab approval.""" + +from __future__ import annotations + +import io +import unittest +from contextlib import redirect_stderr +from typing import Any + +from ocr_toolkit.posting import ( + approval, + formatting, + gitlab, + gitlab_approval, + markers, + settings, + snapshot, + workflow, +) +from ocr_toolkit.posting.payloads import build_marked_note_body +from ocr_toolkit.posting.snapshot import BotCommentRefs +from ocr_toolkit.result_contract import ReviewOutcome +from tests.support import cleared_env, gitlab_config, patched_attr, patched_env + + +def complete_outcome() -> ReviewOutcome: + """Return an authoritative complete synthetic OCR outcome.""" + + return ReviewOutcome( + status="complete", + kind="clean", + budget_exceeded=False, + manifest_present=True, + selected_count=1, + completed_count=1, + ) + + +def finding(category: Any = "style", severity: Any = "low") -> dict[str, Any]: + """Return one synthetic structured finding.""" + + return {"category": category, "severity": severity} + + +def eligibility( + comments: list[dict[str, Any]] | None = None, + *, + outcome: ReviewOutcome | None = None, + warnings: list[Any] | None = None, + omitted: int = 0, + setting: settings.BooleanSetting | None = None, +) -> approval.ApprovalEligibility: + """Evaluate the fixed policy with concise synthetic defaults.""" + + return approval.evaluate_approval_policy( + setting or settings.BooleanSetting(True), + outcome or complete_outcome(), + comments or [], + warnings or [], + omitted, + ) + + +class ApprovalSettingTests(unittest.TestCase): + """Lock default-on configuration and fail-closed invalid values.""" + + def tearDown(self) -> None: + settings.auto_approve.cache_clear() + + def test_defaults_on_and_accepts_complete_boolean_vocabulary(self) -> None: + with cleared_env("OCR_AUTO_APPROVE"): + settings.auto_approve.cache_clear() + self.assertEqual(settings.auto_approve(), settings.BooleanSetting(True)) + for value in ("true", "1", "yes", "on", "TRUE"): + with self.subTest(value=value), patched_env(OCR_AUTO_APPROVE=value): + settings.auto_approve.cache_clear() + self.assertEqual(settings.auto_approve(), settings.BooleanSetting(True)) + for value in ("false", "0", "no", "off", "FALSE"): + with self.subTest(value=value), patched_env(OCR_AUTO_APPROVE=value): + settings.auto_approve.cache_clear() + self.assertEqual(settings.auto_approve(), settings.BooleanSetting(False)) + + def test_invalid_value_disables_without_logging_raw_input(self) -> None: + stderr = io.StringIO() + with patched_env(OCR_AUTO_APPROVE="secret-token-value"), redirect_stderr(stderr): + settings.auto_approve.cache_clear() + value = settings.auto_approve() + + self.assertEqual(value, settings.BooleanSetting(False, valid=False)) + self.assertNotIn("secret-token-value", stderr.getvalue()) + + +class ApprovalPolicyTests(unittest.TestCase): + """Lock every allow and deny branch in the fixed first-release policy.""" + + def test_clean_zero_and_one_to_three_allowed_findings_are_eligible(self) -> None: + self.assertTrue(eligibility().eligible) + for count in range(1, 4): + with self.subTest(count=count): + self.assertTrue(eligibility([finding()] * count).eligible) + + def test_four_findings_and_every_blocking_category_are_ineligible(self) -> None: + self.assertFalse(eligibility([finding()] * 4).eligible) + for category in ("bug", "security", "test", "performance", "other"): + with self.subTest(category=category): + self.assertFalse(eligibility([finding(category=category)]).eligible) + + def test_blocking_or_malformed_metadata_is_ineligible(self) -> None: + for severity in ("critical", "high", "medium", "LOW", None, 1, True): + with self.subTest(severity=severity): + self.assertFalse(eligibility([finding(severity=severity)]).eligible) + for category in ("unknown", "STYLE", None, 1, True): + with self.subTest(category=category): + self.assertFalse(eligibility([finding(category=category)]).eligible) + + def test_incomplete_warning_omitted_budget_waived_and_legacy_block(self) -> None: + legacy = ReviewOutcome("success", "clean", False) + partial = ReviewOutcome("partial", "partial", False, manifest_present=True) + budget = ReviewOutcome("partial", "partial", True, manifest_present=True) + waived = ReviewOutcome("complete", "clean", False, manifest_present=True, waived_count=1) + for name, decision in ( + ("legacy", eligibility(outcome=legacy)), + ("partial", eligibility(outcome=partial)), + ("budget", eligibility(outcome=budget)), + ("waived", eligibility(outcome=waived)), + ("warning", eligibility(warnings=["synthetic warning"])), + ("omitted", eligibility(omitted=1)), + ): + with self.subTest(name=name): + self.assertFalse(decision.eligible) + + self.assertFalse(eligibility(outcome=partial).may_unapprove) + self.assertTrue(eligibility(warnings=["synthetic warning"]).may_unapprove) + + def test_disabled_and_invalid_setting_never_allow_unapproval(self) -> None: + for setting in ( + settings.BooleanSetting(False), + settings.BooleanSetting(False, valid=False), + ): + decision = eligibility(setting=setting) + self.assertEqual(decision.result.status, approval.ApprovalStatus.DISABLED) + self.assertFalse(decision.may_unapprove) + + def test_eligible_provisional_summary_fails_closed_until_readback(self) -> None: + provisional = approval.provisional_approval_result(eligibility()) + + self.assertEqual(provisional.status, approval.ApprovalStatus.FAILED) + self.assertIn("not yet been confirmed", provisional.reason) + + disabled = eligibility(setting=settings.BooleanSetting(False)) + self.assertEqual( + approval.provisional_approval_result(disabled), + disabled.result, + ) + + +class ApprovalReceiptTests(unittest.TestCase): + """Require versioned same-user ownership proof before unapproval.""" + + def test_receipt_round_trip_and_malformed_rejection(self) -> None: + receipt = markers.ManagedApprovalReceipt(7, "a" * 40) + body = build_marked_note_body( + markers.build_summary_run_marker("b" * 32) + + "\n" + + markers.build_managed_approval_receipt(receipt) + + "\n## Open Code Review\n" + ) + + self.assertEqual(markers.managed_approval_receipt_from_body(body), receipt) + self.assertIsNone( + markers.managed_approval_receipt_from_body( + body + "\n" + markers.build_managed_approval_receipt(receipt) + ) + ) + self.assertIsNone( + markers.managed_approval_receipt_from_body( + "" + ) + ) + + def test_receipt_embedded_in_model_controlled_fallback_is_rejected(self) -> None: + receipt = markers.ManagedApprovalReceipt(7, "a" * 40) + forged = build_marked_note_body( + "**Open Code Review fallback comments**\n\n" + + markers.build_summary_run_marker("b" * 32) + + "\n" + + markers.build_managed_approval_receipt(receipt) + + "\n## Open Code Review\n" + ) + + self.assertIsNone(markers.managed_approval_receipt_from_body(forged)) + + def test_snapshot_keeps_owned_receipt_when_notes_and_discussions_overlap(self) -> None: + receipt = markers.ManagedApprovalReceipt(7, "a" * 40) + body = build_marked_note_body( + markers.build_summary_run_marker("b" * 32) + + "\n" + + markers.build_managed_approval_receipt(receipt) + + "\n## Open Code Review\nsummary" + ) + + def paginate(_config: Any, endpoint: str, **_kwargs: Any) -> list[Any]: + if endpoint.startswith("/notes"): + return [{"id": 10, "author": {"id": 7}, "body": body}] + if endpoint == "/discussions": + return [ + {"id": "discussion", "notes": [{"id": 10, "author": {"id": 7}, "body": body}]} + ] + raise AssertionError(endpoint) + + with ( + patched_env(OCR_POST_MODE="direct"), + patched_attr(snapshot, "api_get_paginated", paginate), + ): + settings.post_mode.cache_clear() + refs = snapshot.collect_previous_bot_comment_refs(gitlab_config()) + settings.post_mode.cache_clear() + + self.assertIsNotNone(refs) + self.assertEqual(refs and refs.managed_approval_receipt, receipt) + + +class GitLabApprovalAdapterTests(unittest.TestCase): + """Exercise synchronization, exact-SHA writes, and bounded readback.""" + + SHA = "a" * 40 + + @staticmethod + def api_sequence( + own_approved: bool = False, + *, + sha: str = SHA, + detailed_status: str = "mergeable", + patch_id: str | None = "b" * 40, + ) -> Any: + def request(_config: Any, endpoint: str, **_kwargs: Any) -> Any: + if endpoint == "": + return {"sha": sha, "state": "opened", "detailed_merge_status": detailed_status} + if endpoint == "/approvals": + return {"approved_by": ([{"user": {"id": 7}}] if own_approved else [])} + raise AssertionError(endpoint) + + return request + + def test_waits_for_sync_without_writing_and_rejects_stale_sha(self) -> None: + sleeps: list[float] = [] + mr_reads = 0 + + def request(*args: Any, **kwargs: Any) -> Any: + nonlocal mr_reads + if args[1] == "": + pending = mr_reads == 0 + mr_reads += 1 + return { + "sha": self.SHA, + "state": "opened", + "detailed_merge_status": "checking" if pending else "mergeable", + } + return self.api_sequence()(*args, **kwargs) + + def latest(_config: Any) -> dict[str, Any]: + pending = mr_reads == 1 + return { + "id": mr_reads, + "head_commit_sha": self.SHA, + "patch_id_sha": None if pending else "b" * 40, + } + + with ( + patched_attr(gitlab, "api_request", request), + patched_attr(gitlab_approval, "_latest_diff_version", latest), + ): + state, error = gitlab_approval.wait_for_synchronized_approval_state( + gitlab_config(), self.SHA, sleep=sleeps.append + ) + + self.assertIsNone(error) + self.assertEqual(state, gitlab_approval.GitLabApprovalState(False)) + self.assertEqual(sleeps, [2.0]) + + with ( + patched_attr(gitlab, "api_request", self.api_sequence(sha="c" * 40)), + patched_attr( + gitlab_approval, + "_latest_diff_version", + lambda _config: { + "id": 1, + "head_commit_sha": "c" * 40, + "patch_id_sha": "b" * 40, + }, + ), + ): + stale_state, stale = gitlab_approval.wait_for_synchronized_approval_state( + gitlab_config(), self.SHA, sleep=lambda _seconds: None + ) + self.assertIsNone(stale_state) + self.assertEqual(stale and stale.status, approval.ApprovalStatus.SKIPPED) + + def test_eligible_review_approves_exact_sha_and_confirms_own_user(self) -> None: + approval_reads = 0 + approve_shas: list[str] = [] + + def request(*args: Any, **kwargs: Any) -> Any: + nonlocal approval_reads + endpoint = args[1] + own = approval_reads > 0 + if endpoint == "/approvals": + approval_reads += 1 + return self.api_sequence(own_approved=own)(*args, **kwargs) + + def approve(_config: Any, sha: str) -> gitlab.GitLabWriteResult: + approve_shas.append(sha) + return gitlab.GitLabWriteResult("posted") + + with ( + patched_attr(gitlab, "api_request", request), + patched_attr( + gitlab_approval, + "_latest_diff_version", + lambda _config: { + "id": 2, + "head_commit_sha": self.SHA, + "patch_id_sha": "b" * 40, + }, + ), + patched_attr(gitlab, "approve_merge_request", approve), + ): + result = gitlab_approval.execute_approval( + gitlab_config(), eligibility(), self.SHA, None, sleep=lambda _seconds: None + ) + + self.assertEqual(approve_shas, [self.SHA]) + self.assertEqual(result.result.status, approval.ApprovalStatus.APPROVED) + self.assertEqual(result.receipt, markers.ManagedApprovalReceipt(7, self.SHA)) + + def test_ambiguous_approve_is_not_retried(self) -> None: + writes: list[str] = [] + + def approve(_config: Any, sha: str) -> gitlab.GitLabWriteResult: + writes.append(sha) + return gitlab.GitLabWriteResult("write_failed") + + with ( + patched_attr(gitlab, "api_request", self.api_sequence()), + patched_attr( + gitlab_approval, + "_latest_diff_version", + lambda _config: { + "id": 2, + "head_commit_sha": self.SHA, + "patch_id_sha": "b" * 40, + }, + ), + patched_attr(gitlab, "approve_merge_request", approve), + ): + result = gitlab_approval.execute_approval( + gitlab_config(), eligibility(), self.SHA, None, sleep=lambda _seconds: None + ) + + self.assertEqual(writes, [self.SHA]) + self.assertEqual(result.result.status, approval.ApprovalStatus.FAILED) + + def test_existing_managed_approval_for_other_sha_is_not_claimed_as_exact(self) -> None: + receipt = markers.ManagedApprovalReceipt(7, "c" * 40) + with ( + patched_attr(gitlab, "api_request", self.api_sequence(own_approved=True)), + patched_attr( + gitlab_approval, + "_latest_diff_version", + lambda _config: { + "id": 2, + "head_commit_sha": self.SHA, + "patch_id_sha": "b" * 40, + }, + ), + ): + result = gitlab_approval.execute_approval( + gitlab_config(), eligibility(), self.SHA, receipt + ) + + self.assertEqual(result.result.status, approval.ApprovalStatus.SKIPPED) + self.assertEqual(result.receipt, receipt) + + def test_provider_writes_use_only_own_user_approval_endpoints(self) -> None: + calls: list[tuple[str, dict[str, Any], str]] = [] + + def write( + url: str, + api_token: str, + auth_header: str, + data: dict[str, Any], + method: str = "POST", + **_kwargs: Any, + ) -> gitlab.GitLabWriteResult: + self.assertEqual(api_token, "token") + self.assertEqual(auth_header, "PRIVATE-TOKEN") + calls.append((url, data, method)) + return gitlab.GitLabWriteResult("posted") + + with patched_attr(gitlab, "api_write_url_detailed", write): + gitlab.approve_merge_request(gitlab_config(), self.SHA) + gitlab.unapprove_merge_request(gitlab_config()) + + self.assertEqual(calls[0][0].rsplit("/", 1)[-1], "approve") + self.assertEqual(calls[0][1], {"sha": self.SHA}) + self.assertEqual(calls[1][0].rsplit("/", 1)[-1], "unapprove") + self.assertEqual(calls[1][1], {}) + self.assertNotIn("reset_approvals", repr(calls)) + + def test_unapproves_only_own_managed_approval_after_authoritative_result(self) -> None: + receipt = markers.ManagedApprovalReceipt(7, "c" * 40) + writes: list[str] = [] + approval_reads = 0 + + def request(*args: Any, **kwargs: Any) -> Any: + nonlocal approval_reads + own = approval_reads == 0 + if args[1] == "/approvals": + approval_reads += 1 + return self.api_sequence(own_approved=own)(*args, **kwargs) + + def unapprove(_config: Any) -> gitlab.GitLabWriteResult: + writes.append("unapprove") + return gitlab.GitLabWriteResult("posted") + + with ( + patched_attr(gitlab, "api_request", request), + patched_attr( + gitlab_approval, + "_latest_diff_version", + lambda _config: { + "id": 2, + "head_commit_sha": self.SHA, + "patch_id_sha": "b" * 40, + }, + ), + patched_attr(gitlab, "unapprove_merge_request", unapprove), + ): + result = gitlab_approval.execute_approval( + gitlab_config(), + eligibility([finding(category="security")]), + self.SHA, + receipt, + sleep=lambda _seconds: None, + ) + + self.assertEqual(writes, ["unapprove"]) + self.assertIsNone(result.receipt) + self.assertEqual(result.result.status, approval.ApprovalStatus.NOT_ELIGIBLE) + + def test_disabled_or_partial_review_preserves_receipt_without_api(self) -> None: + receipt = markers.ManagedApprovalReceipt(7, "c" * 40) + for decision in ( + eligibility(setting=settings.BooleanSetting(False)), + eligibility(outcome=ReviewOutcome("partial", "partial", False, True)), + ): + with self.subTest(status=decision.result.status): + result = gitlab_approval.execute_approval( + gitlab_config(), decision, self.SHA, receipt + ) + self.assertEqual(result.receipt, receipt) + + def test_latest_diff_version_uses_highest_valid_id_not_response_order(self) -> None: + versions = [ + {"id": 2, "head_commit_sha": "b" * 40}, + {"id": 5, "head_commit_sha": "e" * 40}, + {"id": 3, "head_commit_sha": "c" * 40}, + ] + with patched_attr(gitlab, "api_get_paginated", lambda *_args, **_kwargs: versions): + latest = gitlab_approval._latest_diff_version(gitlab_config()) + + self.assertEqual(latest, versions[1]) + + +class ApprovalWorkflowTests(unittest.TestCase): + """Keep advisory publication successful and approval status truthful.""" + + SHA = "a" * 40 + RUN_ID = "b" * 32 + + def tearDown(self) -> None: + settings.post_mode.cache_clear() + + def test_current_summary_readback_requires_one_owned_run_marker(self) -> None: + body = build_marked_note_body(markers.build_summary_run_marker(self.RUN_ID) + "\nsummary") + notes = [ + {"id": 9, "author": {"id": 7}, "body": body}, + {"id": 8, "author": {"id": 8}, "body": body}, + ] + with patched_attr(gitlab, "api_get_paginated", lambda *_args, **_kwargs: notes): + self.assertEqual( + workflow.find_current_summary_note(gitlab_config(), self.RUN_ID), + 9, + ) + + notes.append({"id": 10, "author": {"id": 7}, "body": body}) + with patched_attr(gitlab, "api_get_paginated", lambda *_args, **_kwargs: notes): + self.assertIsNone(workflow.find_current_summary_note(gitlab_config(), self.RUN_ID)) + + def test_finalize_orders_publish_approval_summary_update_and_cleanup(self) -> None: + calls: list[str] = [] + receipt = markers.ManagedApprovalReceipt(7, self.SHA) + approved = gitlab_approval.ApprovalExecution( + approval.ApprovalResult( + approval.ApprovalStatus.APPROVED, + "GitLab confirmed the toolkit user's exact-SHA approval", + managed=True, + ), + receipt, + ) + + def publish(*_args: Any) -> bool: + calls.append("publish") + return True + + def approve(*_args: Any, **_kwargs: Any) -> gitlab_approval.ApprovalExecution: + calls.append("approve") + return approved + + def update_summary(*_args: Any) -> bool: + calls.append("summary") + return True + + def cleanup(*_args: Any) -> None: + calls.append("cleanup") + + with ( + patched_attr(workflow, "finalize_posting", publish), + patched_attr(workflow, "execute_approval", approve), + patched_attr(workflow, "replace_current_summary", update_summary), + patched_attr(workflow, "finalize_previous_review_state", cleanup), + ): + exit_code = workflow.finalize_review_approval( + gitlab_config(), + BotCommentRefs(), + complete_outcome(), + [1], + eligibility(), + self.SHA, + self.RUN_ID, + lambda result: approval.approval_summary_line(result), + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(calls, ["publish", "approve", "summary", "cleanup"]) + + def test_disabled_approval_preserves_receipt_without_summary_rewrite(self) -> None: + calls: list[str] = [] + receipt = markers.ManagedApprovalReceipt(7, "c" * 40) + decision = eligibility(setting=settings.BooleanSetting(False)) + + def update_summary(*_args: Any) -> bool: + calls.append("summary") + return True + + with ( + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr( + workflow, + "execute_approval", + lambda *_args, **_kwargs: gitlab_approval.ApprovalExecution( + decision.result, receipt + ), + ), + patched_attr(workflow, "replace_current_summary", update_summary), + patched_attr(workflow, "finalize_previous_review_state", lambda *_args: None), + ): + exit_code = workflow.finalize_review_approval( + gitlab_config(), + BotCommentRefs(managed_approval_receipt=receipt), + complete_outcome(), + [], + decision, + self.SHA, + self.RUN_ID, + lambda result: approval.approval_summary_line(result), + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(calls, []) + + def test_approval_failure_is_nonfatal_unless_strict(self) -> None: + failed = gitlab_approval.ApprovalExecution( + approval.ApprovalResult( + approval.ApprovalStatus.FAILED, + "the GitLab approve result was not safely confirmed", + ) + ) + for strict, expected in (("false", 0), ("true", 1)): + with self.subTest(strict=strict), patched_env(OCR_STRICT_POSTING=strict): + with ( + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr( + workflow, + "execute_approval", + lambda *_args, **_kwargs: failed, + ), + patched_attr(workflow, "replace_current_summary", lambda *_args: True), + patched_attr( + workflow, + "finalize_previous_review_state", + lambda *_args: None, + ), + ): + exit_code = workflow.finalize_review_approval( + gitlab_config(), + BotCommentRefs(), + complete_outcome(), + [], + eligibility(), + self.SHA, + self.RUN_ID, + lambda result: approval.approval_summary_line(result), + ) + self.assertEqual(exit_code, expected) + + def test_summary_update_failure_never_rolls_back_published_review(self) -> None: + calls: list[str] = [] + failed = gitlab_approval.ApprovalExecution( + approval.ApprovalResult(approval.ApprovalStatus.APPROVED, "confirmed") + ) + + def update_summary(*_args: Any) -> bool: + calls.append("summary-failed") + return False + + def cleanup(*_args: Any) -> None: + calls.append("cleanup") + + with ( + patched_env(OCR_STRICT_POSTING="true"), + patched_attr(workflow, "finalize_posting", lambda *_args: True), + patched_attr( + workflow, + "execute_approval", + lambda *_args, **_kwargs: failed, + ), + patched_attr(workflow, "replace_current_summary", update_summary), + patched_attr(workflow, "finalize_previous_review_state", cleanup), + redirect_stderr(io.StringIO()), + ): + exit_code = workflow.finalize_review_approval( + gitlab_config(), + BotCommentRefs(), + complete_outcome(), + [], + eligibility(), + self.SHA, + self.RUN_ID, + lambda result: approval.approval_summary_line(result), + ) + + self.assertEqual(exit_code, 1) + self.assertEqual(calls, ["summary-failed", "cleanup"]) + + def test_summary_renders_exactly_one_bounded_approval_state(self) -> None: + rendered = formatting.summarize_result( + total=0, + inline_count=0, + fallback_count=0, + warning_count=0, + approval_result=approval.ApprovalResult( + approval.ApprovalStatus.NOT_ELIGIBLE, + "the OCR review reported warnings", + ), + emoji=False, + ) + + self.assertEqual(rendered.count("Automatic approval:"), 1) + self.assertIn("`not eligible`", rendered) + + +if __name__ == "__main__": + unittest.main() From 5acbf155b9349ed2e4372ffe69b1f7bc5238b66e Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:37:08 +0200 Subject: [PATCH 4/7] Harden stable release authorization and recovery --- .github/workflows/release.yml | 329 ++++++++++++++++++- AGENTS.md | 6 +- PLANS.md | 59 +++- changelog.d/69.doc.md | 1 + docs/codex/AGENT_EXECUTION_PITFALLS.md | 6 +- docs/engineering/execution_history/README.md | 2 +- docs/engineering/project_principles.md | 2 +- docs/release.md | 22 +- scripts/bounded_github_api.sh | 54 +++ scripts/release_authorization.py | 230 ++++++++++++- scripts/release_issue_receipt.py | 128 ++++++++ scripts/release_receipt.py | 256 +++++++++++++++ scripts/testpypi_preview.py | 33 +- scripts/verify_registry_artifacts.sh | 32 ++ scripts/verify_registry_provenance.py | 95 ++++++ tests/test_release_authorization.py | 260 ++++++++++++++- tests/test_release_process_docs.py | 5 +- tests/test_release_receipt.py | 321 ++++++++++++++++++ tests/test_testpypi_preview.py | 33 +- 19 files changed, 1816 insertions(+), 58 deletions(-) create mode 100755 scripts/bounded_github_api.sh create mode 100755 scripts/release_issue_receipt.py create mode 100644 scripts/release_receipt.py create mode 100644 scripts/verify_registry_provenance.py create mode 100644 tests/test_release_receipt.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 924b183..b9a882a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,10 @@ on: description: Exact full merge commit SHA required: true type: string + reviewed-head: + description: Exact reviewed release pull-request head SHA + required: true + type: string permissions: contents: read @@ -58,18 +62,24 @@ jobs: timeout-minutes: 5 permissions: contents: read + issues: read # Validate every tracked issue before any registry publication. pull-requests: read # Resolve the authoritative merged release PR. outputs: approved: ${{ steps.authorize.outputs.approved }} branch: ${{ steps.authorize.outputs.branch }} + base: ${{ steps.authorize.outputs.base }} commit: ${{ steps.authorize.outputs.commit }} + head: ${{ steps.authorize.outputs.head }} + issues: ${{ steps.authorize.outputs.issues }} + merged-at: ${{ steps.authorize.outputs.merged-at }} pr-number: ${{ steps.authorize.outputs.pr-number }} title: ${{ steps.authorize.outputs.title }} + tree: ${{ steps.authorize.outputs.tree }} version: ${{ steps.authorize.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: main + ref: ${{ github.event.pull_request.merge_commit_sha || inputs['merge-commit'] }} persist-credentials: false - id: authorize env: @@ -79,24 +89,82 @@ jobs: INPUT_PR_NUMBER: ${{ inputs['pull-request-number'] }} INPUT_VERSION: ${{ inputs.version }} INPUT_COMMIT: ${{ inputs['merge-commit'] }} + INPUT_HEAD: ${{ inputs['reviewed-head'] }} REPOSITORY: ${{ github.repository }} run: | if [ "${EVENT_NAME}" = workflow_dispatch ]; then PR_NUMBER=${INPUT_PR_NUMBER} REQUESTED_VERSION=${INPUT_VERSION} REQUESTED_COMMIT=${INPUT_COMMIT} + REQUESTED_HEAD=${INPUT_HEAD} else PR_NUMBER=${EVENT_PR_NUMBER} REQUESTED_VERSION= REQUESTED_COMMIT= + REQUESTED_HEAD= fi - gh api "repos/${REPOSITORY}/pulls/${PR_NUMBER}" > /tmp/release-pr.json + test "$(scripts/bounded_github_api.sh \ + "repos/${REPOSITORY}/pulls/${PR_NUMBER}" /tmp/release-pr.json)" = 200 + HEAD_SHA=$(jq -r .head.sha /tmp/release-pr.json) + MERGE_SHA=$(jq -r .merge_commit_sha /tmp/release-pr.json) + case "${HEAD_SHA}" in + *[!0-9a-f]*|'') + echo "pull request returned invalid commit identities" >&2 + exit 1 + ;; + esac + case "${MERGE_SHA}" in + *[!0-9a-f]*|'') + echo "pull request returned invalid commit identities" >&2 + exit 1 + ;; + esac + test "${#HEAD_SHA}" -eq 40 + test "${#MERGE_SHA}" -eq 40 + test "$(scripts/bounded_github_api.sh \ + "repos/${REPOSITORY}/commits/${HEAD_SHA}" /tmp/release-head-commit.json)" = 200 + test "$(scripts/bounded_github_api.sh \ + "repos/${REPOSITORY}/commits/${MERGE_SHA}" /tmp/release-merge-commit.json)" = 200 + test "$(scripts/bounded_github_api.sh \ + "repos/${REPOSITORY}/contents/.release-metadata.json?ref=${MERGE_SHA}" \ + /tmp/release-metadata-contents.json)" = 200 + test "$(scripts/bounded_github_api.sh \ + "repos/${REPOSITORY}/commits/${HEAD_SHA}/check-runs?filter=latest&per_page=100" \ + /tmp/release-check-runs.json)" = 200 + # Effective rules are a public repository contract. Reading them + # anonymously avoids requiring repository-administration permission. + test "$(scripts/bounded_github_api.sh \ + "repos/${REPOSITORY}/rules/branches/main" \ + /tmp/effective-main-rules.json anonymous)" = 200 + jq '{rules: .}' /tmp/effective-main-rules.json > /tmp/main-ruleset.json python scripts/release_authorization.py \ --pr-json /tmp/release-pr.json \ --repository "${REPOSITORY}" \ + --head-commit-json /tmp/release-head-commit.json \ + --merge-commit-json /tmp/release-merge-commit.json \ + --check-runs-json /tmp/release-check-runs.json \ + --ruleset-json /tmp/main-ruleset.json \ + --release-metadata-contents-json /tmp/release-metadata-contents.json \ --requested-version "${REQUESTED_VERSION}" \ --requested-commit "${REQUESTED_COMMIT}" \ + --requested-head "${REQUESTED_HEAD}" \ --github-output "${GITHUB_OUTPUT}" + - name: Validate tracked release issues before publication + env: + GH_TOKEN: ${{ github.token }} + ISSUES: ${{ steps.authorize.outputs.issues }} + REPOSITORY: ${{ github.repository }} + run: | + while IFS= read -r issue; do + test -n "${issue}" || continue + test "$(scripts/bounded_github_api.sh \ + "repos/${REPOSITORY}/issues/${issue}" "/tmp/release-issue-${issue}.json")" = 200 + python scripts/release_issue_receipt.py \ + --issue-json "/tmp/release-issue-${issue}.json" \ + --issue "${issue}" --validate-issue-only >/dev/null + done <&2; exit 1 ;; + esac + test "${asset_id}" -gt 0 + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \ + "${destination}" authenticated 200 "${max_bytes}" application/octet-stream)" = 200 + } + load_issue_comments() { + issue=$1 + comments=$2 + printf '[]\n' > "${comments}" + for page in 1 2 3 4 5; do + page_file="/tmp/issue-${issue}-comments-${page}.json" + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/issues/${issue}/comments?per_page=100&page=${page}" \ + "${page_file}")" = 200 + page_count=$(jq 'if type == "array" then length else -1 end' "${page_file}") + test "${page_count}" -ge 0 && test "${page_count}" -le 100 + jq -s '.[0] + .[1]' "${comments}" "${page_file}" > "${comments}.next" + mv "${comments}.next" "${comments}" + if [ "${page_count}" -lt 100 ]; then + return + fi + done + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/issues/${issue}/comments?per_page=1&page=501" \ + "/tmp/issue-${issue}-comments-overflow.json")" = 200 + test "$(jq 'if type == "array" then length else -1 end' \ + "/tmp/issue-${issue}-comments-overflow.json")" = 0 || { + echo "issue ${issue} has too many comments for bounded receipt lookup" >&2 + exit 1 + } + } python scripts/release_notes.py --version "${VERSION}" --output /tmp/release-notes.md + for artifact in dist/*; do + gh attestation verify "${artifact}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/release.yml" \ + --source-digest "${EXPECTED_COMMIT}" \ + --deny-self-hosted-runners + done git config user.name "release automation" git config user.email "release-automation@users.noreply.github.com" if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}" + test "$(git cat-file -t "${TAG}")" = tag test "$(git rev-parse "${TAG}^{commit}")" = "${EXPECTED_COMMIT}" else git tag -a "${TAG}" -m "${TAG}" "${EXPECTED_COMMIT}" git push origin "${TAG}" fi - release_exists=false - if gh release view "${TAG}" >/dev/null 2>&1; then - release_exists=true - else - gh release create "${TAG}" --draft --verify-tag --title "${TAG}" --notes-file /tmp/release-notes.md - fi - release_is_draft=$(gh release view "${TAG}" --json isDraft --jq .isDraft) + release_status=$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + /tmp/github-release-state.json authenticated 200,404) + case "${release_status}" in + 200) release_exists=true ;; + 404) + release_exists=false + gh release create "${TAG}" --draft --verify-tag \ + --title "${TAG}" --notes-file /tmp/release-notes.md + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + /tmp/github-release-state.json)" = 200 + ;; + esac + release_is_draft=$(jq -r .draft /tmp/github-release-state.json) + test "${release_is_draft}" = true || test "${release_is_draft}" = false if [ "${release_exists}" = false ] || [ "${release_is_draft}" = true ]; then - gh release upload "${TAG}" dist/* artifact-hashes.json SHA256SUMS --clobber + jq '{assets}' /tmp/github-release-state.json > /tmp/github-release-upload-assets.json + receipt_count=$(jq '[.assets[] | select(.name == "release-receipt.json")] | length' \ + /tmp/github-release-upload-assets.json) + case "${receipt_count}" in + 0) + python scripts/release_receipt.py \ + --version "${VERSION}" \ + --tag "${TAG}" \ + --release-pr "${RELEASE_PR}" \ + --issues "${ISSUES}" \ + --base "${EXPECTED_BASE}" \ + --head "${EXPECTED_HEAD}" \ + --merge "${EXPECTED_COMMIT}" \ + --tree "${EXPECTED_TREE}" \ + --run-id "${GITHUB_RUN_ID}" \ + --run-attempt "${GITHUB_RUN_ATTEMPT}" \ + --authorized-at "${AUTHORIZED_AT}" \ + --hashes artifact-hashes.json \ + --output release-receipt.json + ;; + 1) + bounded_release_download release-receipt.json release-receipt.json 1048576 + python scripts/release_receipt.py \ + --version "${VERSION}" \ + --tag "${TAG}" \ + --release-pr "${RELEASE_PR}" \ + --issues "${ISSUES}" \ + --base "${EXPECTED_BASE}" \ + --head "${EXPECTED_HEAD}" \ + --merge "${EXPECTED_COMMIT}" \ + --tree "${EXPECTED_TREE}" \ + --authorized-at "${AUTHORIZED_AT}" \ + --hashes artifact-hashes.json \ + --validate-existing release-receipt.json + ;; + *) echo "duplicate GitHub Release receipt asset" >&2; exit 1 ;; + esac + for asset in dist/* artifact-hashes.json SHA256SUMS release-receipt.json; do + name=$(basename "${asset}") + asset_count=$(jq --arg name "${name}" '[.assets[] | select(.name == $name)] | length' \ + /tmp/github-release-upload-assets.json) + case "${asset_count}" in + 0) gh release upload "${TAG}" "${asset}" ;; + 1) + bounded_release_download "${name}" "/tmp/existing-${name}" 10485760 + cmp "${asset}" "/tmp/existing-${name}" + ;; + *) echo "duplicate GitHub Release asset: ${name}" >&2; exit 1 ;; + esac + done fi release_dir=/tmp/github-release-assets mkdir -p "${release_dir}" - gh release download "${TAG}" --dir "${release_dir}" + for name in \ + "open_code_review_toolkit-${VERSION}-py3-none-any.whl" \ + "open_code_review_toolkit-${VERSION}.tar.gz" \ + artifact-hashes.json SHA256SUMS release-receipt.json; do + bounded_release_download "${name}" "${release_dir}/${name}" 10485760 + done expected=$(printf '%s\n' \ "open_code_review_toolkit-${VERSION}-py3-none-any.whl" \ "open_code_review_toolkit-${VERSION}.tar.gz" \ - artifact-hashes.json SHA256SUMS | sort) + artifact-hashes.json SHA256SUMS release-receipt.json | sort) actual=$(find "${release_dir}" -type f -maxdepth 1 -exec basename {} \; | sort) test "${actual}" = "${expected}" (cd "${release_dir}" && sha256sum --check --strict SHA256SUMS) cmp artifact-hashes.json "${release_dir}/artifact-hashes.json" cmp SHA256SUMS "${release_dir}/SHA256SUMS" - gh release view "${TAG}" --json body,name > /tmp/github-release.json + if [ -f release-receipt.json ]; then + cmp release-receipt.json "${release_dir}/release-receipt.json" + else + python scripts/release_receipt.py \ + --version "${VERSION}" \ + --tag "${TAG}" \ + --release-pr "${RELEASE_PR}" \ + --issues "${ISSUES}" \ + --base "${EXPECTED_BASE}" \ + --head "${EXPECTED_HEAD}" \ + --merge "${EXPECTED_COMMIT}" \ + --tree "${EXPECTED_TREE}" \ + --authorized-at "${AUTHORIZED_AT}" \ + --hashes artifact-hashes.json \ + --validate-existing "${release_dir}/release-receipt.json" + fi + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + /tmp/github-release.json)" = 200 python - <<'PY' import json from pathlib import Path import os release = json.loads(Path("/tmp/github-release.json").read_text(encoding="utf-8")) + actual = {"body": release.get("body"), "name": release.get("name")} expected_body = Path("/tmp/release-notes.md").read_text(encoding="utf-8") - if release != {"body": expected_body, "name": os.environ["TAG"]}: + if actual != {"body": expected_body, "name": os.environ["TAG"]}: raise SystemExit("existing GitHub Release metadata does not match") PY if [ "${release_is_draft}" = true ]; then gh release edit "${TAG}" --draft=false fi + export GITHUB_API_VERSION=2026-03-10 + for attempt in 1 2 3 4 5; do + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + /tmp/immutable-release.json)" = 200 + if jq -e '.immutable == true and .draft == false and .prerelease == false' \ + /tmp/immutable-release.json >/dev/null; then + break + fi + test "${attempt}" -lt 5 || { + echo "GitHub Release did not become immutable" >&2 + exit 1 + } + sleep 5 + done + expected_assets=$(printf '%s\n' \ + "open_code_review_toolkit-${VERSION}-py3-none-any.whl" \ + "open_code_review_toolkit-${VERSION}.tar.gz" \ + artifact-hashes.json SHA256SUMS release-receipt.json | sort) + actual_assets=$(jq -r '.assets[].name' /tmp/immutable-release.json | sort) + test "${actual_assets}" = "${expected_assets}" + receipt_sha=$(sha256sum "${release_dir}/release-receipt.json" | awk '{print $1}') + while IFS= read -r issue; do + test -n "${issue}" || continue + case "${issue}" in + *[!0-9]*|'') echo "invalid tracked release issue" >&2; exit 1 ;; + esac + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/issues/${issue}" \ + "/tmp/issue-${issue}.json")" = 200 + load_issue_comments "${issue}" "/tmp/issue-${issue}-comments.json" + issue_receipt_state=$(python scripts/release_issue_receipt.py \ + --issue-json "/tmp/issue-${issue}.json" \ + --comments-json "/tmp/issue-${issue}-comments.json" \ + --issue "${issue}" --version "${VERSION}" --receipt-sha "${receipt_sha}" \ + --body-output "/tmp/issue-${issue}-receipt.md") + issue_state=${issue_receipt_state% *} + comment_state=${issue_receipt_state#* } + case "${comment_state}" in + missing) + test "$(jq 'length < 500' "/tmp/issue-${issue}-comments.json")" = true || { + echo "issue ${issue} cannot accept a receipt within the comment bound" >&2 + exit 1 + } + gh issue comment "${issue}" --repo "${GITHUB_REPOSITORY}" \ + --body-file "/tmp/issue-${issue}-receipt.md" + load_issue_comments "${issue}" "/tmp/issue-${issue}-comments.json" + test "$(python scripts/release_issue_receipt.py \ + --issue-json "/tmp/issue-${issue}.json" \ + --comments-json "/tmp/issue-${issue}-comments.json" \ + --issue "${issue}" --version "${VERSION}" --receipt-sha "${receipt_sha}" \ + --require-comment)" = "${issue_state} matched" + ;; + matched) ;; + esac + if [ "${issue_state}" = open ]; then + gh issue close "${issue}" --repo "${GITHUB_REPOSITORY}" --reason completed + fi + test "$(scripts/bounded_github_api.sh \ + "repos/${GITHUB_REPOSITORY}/issues/${issue}" \ + "/tmp/issue-${issue}-closed.json")" = 200 + python scripts/release_issue_receipt.py \ + --issue-json "/tmp/issue-${issue}-closed.json" \ + --comments-json "/tmp/issue-${issue}-comments.json" \ + --issue "${issue}" --version "${VERSION}" --receipt-sha "${receipt_sha}" \ + --require-comment --require-closed >/dev/null + done <&2; exit 2 ;; +esac +case "${authentication}" in + authenticated) + set -- --header "Authorization: Bearer ${GH_TOKEN:?GH_TOKEN is required}" + ;; + anonymous) + set -- + ;; + *) echo "unsupported GitHub API authentication mode" >&2; exit 2 ;; +esac +case "${expected_statuses}" in + *[!0-9,]*|'') echo "invalid expected GitHub API statuses" >&2; exit 2 ;; +esac +case "${max_bytes}" in + *[!0-9]*|'') echo "invalid GitHub API byte limit" >&2; exit 2 ;; +esac +test "${max_bytes}" -gt 0 && test "${max_bytes}" -le 10485760 || { + echo "GitHub API byte limit is outside the supported range" >&2 + exit 2 +} +case "${accept}" in + application/vnd.github+json|application/octet-stream) ;; + *) echo "unsupported GitHub API media type" >&2; exit 2 ;; +esac + +if ! http_status=$(curl --silent --show-error \ + --location --connect-timeout 10 --max-time 60 --max-filesize "${max_bytes}" \ + --proto '=https' --proto-redir '=https' \ + --header "Accept: ${accept}" \ + --header "X-GitHub-Api-Version: ${GITHUB_API_VERSION:-2022-11-28}" \ + "$@" --output "${output}" --write-out '%{http_code}' \ + "https://api.github.com/${endpoint}"); then + echo "bounded GitHub API read failed: ${endpoint}" >&2 + exit 1 +fi +case ",${expected_statuses}," in + *,"${http_status}",*) ;; + *) echo "unexpected GitHub API status ${http_status}: ${endpoint}" >&2; exit 1 ;; +esac +printf '%s\n' "${http_status}" diff --git a/scripts/release_authorization.py b/scripts/release_authorization.py index 510391c..4915079 100644 --- a/scripts/release_authorization.py +++ b/scripts/release_authorization.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import base64 import json import re from pathlib import Path @@ -11,17 +12,196 @@ COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)+$") +RELEASE_METADATA_SCHEMA = "ocr-toolkit.release-authorization/v1" class AuthorizationError(ValueError): """The pull request does not authorize a stable release.""" +def _full_commit(value: Any, field: str) -> str: + """Return one validated full lowercase commit SHA.""" + + if not isinstance(value, str) or not COMMIT_RE.fullmatch(value): + raise AuthorizationError(f"{field} is not a full lowercase SHA") + return value + + +def _commit_metadata( + payload: dict[str, Any], expected_sha: str, field: str +) -> tuple[str, list[str]]: + """Return a validated tree SHA and parent list from one GitHub commit payload.""" + + if _full_commit(payload.get("sha"), f"{field} commit") != expected_sha: + raise AuthorizationError(f"{field} commit response does not match the pull request") + commit = payload.get("commit") + if not isinstance(commit, dict): + raise AuthorizationError(f"{field} commit is missing metadata") + tree = commit.get("tree") + if not isinstance(tree, dict): + raise AuthorizationError(f"{field} commit is missing tree metadata") + tree_sha = _full_commit(tree.get("sha"), f"{field} tree") + raw_parents = payload.get("parents") + if not isinstance(raw_parents, list): + raise AuthorizationError(f"{field} commit is missing parent metadata") + parents: list[str] = [] + for index, parent in enumerate(raw_parents): + if not isinstance(parent, dict): + raise AuthorizationError(f"{field} parent {index} is malformed") + parents.append(_full_commit(parent.get("sha"), f"{field} parent {index}")) + return tree_sha, parents + + +def _release_metadata(payload: dict[str, Any], version: str) -> tuple[int, ...]: + """Validate tracked release authorization metadata and return issue IDs.""" + + if payload.get("schema_version") != RELEASE_METADATA_SCHEMA: + raise AuthorizationError("release metadata schema is unsupported") + if payload.get("version") != version: + raise AuthorizationError("release metadata version does not match the release branch") + issues = payload.get("issues") + if ( + not isinstance(issues, list) + or not issues + or any( + isinstance(issue, bool) or not isinstance(issue, int) or issue <= 0 for issue in issues + ) + or len(set(issues)) != len(issues) + or issues != sorted(issues) + ): + raise AuthorizationError("release metadata issues must be unique sorted positive integers") + return tuple(issues) + + +def _release_metadata_contents(payload: dict[str, Any]) -> dict[str, Any]: + """Decode one exact-ref GitHub Contents API response within a small bound.""" + + content = payload.get("content") + size = payload.get("size") + if ( + payload.get("type") != "file" + or payload.get("name") != ".release-metadata.json" + or payload.get("path") != ".release-metadata.json" + or payload.get("encoding") != "base64" + or isinstance(size, bool) + or not isinstance(size, int) + or size <= 0 + or size > 4096 + or not isinstance(content, str) + ): + raise AuthorizationError("release metadata contents response is malformed") + compact_content = "".join(content.split()) + try: + raw = base64.b64decode(compact_content, validate=True) + except ValueError as exc: + raise AuthorizationError("release metadata contents response is malformed") from exc + if len(raw) != size: + raise AuthorizationError("release metadata contents size does not match") + try: + metadata = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise AuthorizationError("release metadata contents are not valid JSON") from exc + if not isinstance(metadata, dict): + raise AuthorizationError("release metadata must be a JSON object") + return metadata + + +def _required_checks(ruleset: dict[str, Any]) -> dict[tuple[str, int], None]: + """Return the effective main-branch required check contexts and apps.""" + + rules = ruleset.get("rules") + if not isinstance(rules, list): + raise AuthorizationError("effective main rules are missing") + status_rules = [ + rule + for rule in rules + if isinstance(rule, dict) and rule.get("type") == "required_status_checks" + ] + if not status_rules: + raise AuthorizationError("main ruleset has no required status checks") + required: dict[tuple[str, int], None] = {} + for rule in status_rules: + parameters = rule.get("parameters") + checks = parameters.get("required_status_checks") if isinstance(parameters, dict) else None + if not isinstance(checks, list): + raise AuthorizationError("required status check list is malformed") + if parameters.get("strict_required_status_checks_policy") is not True: + raise AuthorizationError("main required checks must use strict base synchronization") + for item in checks: + if not isinstance(item, dict): + raise AuthorizationError("required status check is malformed") + context = item.get("context") + integration_id = item.get("integration_id") + if ( + not isinstance(context, str) + or not context + or isinstance(integration_id, bool) + or not isinstance(integration_id, int) + or integration_id <= 0 + ): + raise AuthorizationError("required status check identity is malformed") + identity = (context, integration_id) + if identity in required: + raise AuthorizationError("required status check identity is duplicated") + required[identity] = None + if not required: + raise AuthorizationError("main ruleset has no required status checks") + return required + + +def _validate_check_runs( + checks_payload: dict[str, Any], ruleset: dict[str, Any], expected_head: str +) -> None: + """Require one complete exact-app run for every live ruleset context.""" + + required = _required_checks(ruleset) + raw_runs = checks_payload.get("check_runs") + total_count = checks_payload.get("total_count") + if ( + not isinstance(raw_runs, list) + or isinstance(total_count, bool) + or not isinstance(total_count, int) + or total_count != len(raw_runs) + or total_count > 100 + ): + raise AuthorizationError("check-runs response is malformed") + matched: dict[tuple[str, int], None] = {} + for run in raw_runs: + if not isinstance(run, dict): + raise AuthorizationError("check-run entry is malformed") + name = run.get("name") + app = run.get("app") + app_id = app.get("id") if isinstance(app, dict) else None + if not isinstance(name, str) or isinstance(app_id, bool) or not isinstance(app_id, int): + continue + identity = (name, app_id) + if identity not in required: + continue + if identity in matched: + raise AuthorizationError(f"reviewed head has duplicate required check: {identity}") + if run.get("head_sha") != expected_head: + raise AuthorizationError( + f"required check is not bound to the reviewed head: {identity}" + ) + if run.get("status") != "completed" or run.get("conclusion") != "success": + raise AuthorizationError(f"reviewed head has unsuccessful required check: {identity}") + matched[identity] = None + missing = [identity for identity in required if identity not in matched] + if missing: + raise AuthorizationError(f"reviewed head is missing required checks: {missing}") + + def authorize_release( payload: dict[str, Any], repository: str, + head_commit_payload: dict[str, Any], + merge_commit_payload: dict[str, Any], + check_runs_payload: dict[str, Any], + ruleset_payload: dict[str, Any], + release_metadata_contents: dict[str, Any], requested_version: str = "", requested_commit: str = "", + requested_head: str = "", ) -> dict[str, str]: """Return safe workflow outputs for one exact repository-owned release merge.""" @@ -39,6 +219,11 @@ def authorize_release( number = payload.get("number") if payload.get("merged") is not True or not payload.get("merged_at"): raise AuthorizationError("pull request is not merged") + merged_at = payload.get("merged_at") + if not isinstance(merged_at, str) or not re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", merged_at + ): + raise AuthorizationError("release merge timestamp is invalid") if base.get("ref") != "main": raise AuthorizationError("release pull request base must be main") if head_repo.get("full_name") != repository: @@ -50,19 +235,36 @@ def authorize_release( raise AuthorizationError("release version must be a final dotted numeric version") if title != f"Release v{version}": raise AuthorizationError("release pull request title does not match its branch") - if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit): - raise AuthorizationError("release merge commit is not a full lowercase SHA") + commit = _full_commit(commit, "release merge commit") + base_sha = _full_commit(base.get("sha"), "release base commit") + head_sha = _full_commit(head.get("sha"), "reviewed release head") if not isinstance(number, int) or number < 1: raise AuthorizationError("release pull request number is invalid") if requested_version and requested_version != version: raise AuthorizationError("requested version does not match the release pull request") if requested_commit and requested_commit != commit: raise AuthorizationError("requested commit does not match the release pull request") + if requested_head and requested_head != head_sha: + raise AuthorizationError("requested head does not match the release pull request") + + head_tree, _head_parents = _commit_metadata(head_commit_payload, head_sha, "head") + merge_tree, merge_parents = _commit_metadata(merge_commit_payload, commit, "merge") + if merge_tree != head_tree: + raise AuthorizationError("release merge tree does not match the reviewed head tree") + if merge_parents != [base_sha]: + raise AuthorizationError("release merge parent does not match the reviewed base") + issues = _release_metadata(_release_metadata_contents(release_metadata_contents), version) + _validate_check_runs(check_runs_payload, ruleset_payload, head_sha) return { "approved": "true", "branch": branch, "commit": commit, + "base": base_sha, + "head": head_sha, + "tree": head_tree, + "issues": ",".join(str(issue) for issue in issues), + "merged-at": merged_at, "pr-number": str(number), "title": title, "version": version, @@ -73,16 +275,38 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--pr-json", type=Path, required=True) parser.add_argument("--repository", required=True) + parser.add_argument("--head-commit-json", type=Path, required=True) + parser.add_argument("--merge-commit-json", type=Path, required=True) + parser.add_argument("--check-runs-json", type=Path, required=True) + parser.add_argument("--ruleset-json", type=Path, required=True) + parser.add_argument("--release-metadata-contents-json", type=Path, required=True) parser.add_argument("--requested-version", default="") parser.add_argument("--requested-commit", default="") + parser.add_argument("--requested-head", default="") parser.add_argument("--github-output", type=Path, required=True) args = parser.parse_args() payload = json.loads(args.pr_json.read_text(encoding="utf-8")) if not isinstance(payload, dict): raise AuthorizationError("pull request response must be a JSON object") + + def load_object(path: Path, description: str) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise AuthorizationError(f"{description} must be a JSON object") + return value + outputs = authorize_release( - payload, args.repository, args.requested_version, args.requested_commit + payload, + args.repository, + load_object(args.head_commit_json, "head commit response"), + load_object(args.merge_commit_json, "merge commit response"), + load_object(args.check_runs_json, "check-runs response"), + load_object(args.ruleset_json, "ruleset response"), + load_object(args.release_metadata_contents_json, "release metadata contents response"), + args.requested_version, + args.requested_commit, + args.requested_head, ) with args.github_output.open("a", encoding="utf-8") as output: for key, value in outputs.items(): diff --git a/scripts/release_issue_receipt.py b/scripts/release_issue_receipt.py new file mode 100755 index 0000000..19f91d7 --- /dev/null +++ b/scripts/release_issue_receipt.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Validate idempotent GitHub issue closure against one release receipt.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +BOT_ID = 41898282 +BOT_LOGIN = "github-actions[bot]" +HASH_RE = re.compile(r"^[0-9a-f]{64}$") +VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)+$") + + +class IssueReceiptError(ValueError): + """Issue state or its toolkit-owned delivery receipt is inconsistent.""" + + +def receipt_body(version: str, issue: int, receipt_sha: str) -> str: + """Return the exact stable-delivery receipt comment body.""" + + if not VERSION_RE.fullmatch(version) or issue <= 0 or not HASH_RE.fullmatch(receipt_sha): + raise IssueReceiptError("release receipt identity is invalid") + marker = f"" + return ( + f"{marker}\n\nStable v{version} delivery is verified by immutable release asset " + f"`release-receipt.json` in v{version} (SHA-256 `{receipt_sha}`)." + ) + + +def issue_state(payload: dict[str, Any], issue: int, *, require_closed: bool) -> str: + """Return one valid tracked-issue state and reject pull requests or wrong closure.""" + + if payload.get("number") != issue or "pull_request" in payload: + raise IssueReceiptError("tracked release issue response is invalid") + state = payload.get("state") + reason = payload.get("state_reason") + if state == "closed" and reason == "completed": + return state + if not require_closed and state == "open" and reason is None: + return state + raise IssueReceiptError("tracked release issue has an incompatible state") + + +def comment_state(comments: list[Any], expected_body: str, *, require_comment: bool) -> str: + """Return whether exactly one GitHub Actions-owned exact receipt exists.""" + + marker = expected_body.splitlines()[0] + marked: list[dict[str, Any]] = [] + for item in comments: + if not isinstance(item, dict): + raise IssueReceiptError("issue comment response is malformed") + body = item.get("body") + user = item.get("user") + if isinstance(body, str) and body.startswith(marker): + marked.append(item) + if not marked and not require_comment: + return "missing" + if len(marked) != 1: + raise IssueReceiptError("release receipt comment is not uniquely owned by GitHub Actions") + user = marked[0].get("user") + if not ( + isinstance(user, dict) + and user.get("login") == BOT_LOGIN + and user.get("id") == BOT_ID + and user.get("type") == "Bot" + ): + raise IssueReceiptError("release receipt comment is not uniquely owned by GitHub Actions") + if marked[0].get("body") != expected_body: + raise IssueReceiptError("release receipt comment body does not match") + return "matched" + + +def load_json(path: Path, *, max_bytes: int) -> Any: + """Load one already network-bounded JSON file under a local size ceiling.""" + + if path.stat().st_size > max_bytes: + raise IssueReceiptError("release issue evidence exceeds its byte limit") + try: + return json.loads(path.read_bytes()) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise IssueReceiptError("release issue evidence is not valid JSON") from exc + + +def main() -> int: + """CLI entrypoint for the stable release workflow.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--issue-json", type=Path, required=True) + parser.add_argument("--comments-json", type=Path) + parser.add_argument("--issue", type=int, required=True) + parser.add_argument("--version", default="") + parser.add_argument("--receipt-sha", default="") + parser.add_argument("--body-output", type=Path) + parser.add_argument("--validate-issue-only", action="store_true") + parser.add_argument("--require-comment", action="store_true") + parser.add_argument("--require-closed", action="store_true") + args = parser.parse_args() + + raw_issue = load_json(args.issue_json, max_bytes=1048576) + if not isinstance(raw_issue, dict): + raise IssueReceiptError("release issue evidence has an invalid top-level shape") + state = issue_state(raw_issue, args.issue, require_closed=args.require_closed) + if args.validate_issue_only: + print(state) + return 0 + if args.comments_json is None: + raise IssueReceiptError("release issue comments are required") + raw_comments = load_json(args.comments_json, max_bytes=6291456) + if not isinstance(raw_comments, list): + raise IssueReceiptError("release issue evidence has an invalid top-level shape") + body = receipt_body(args.version, args.issue, args.receipt_sha) + comment = comment_state( + raw_comments, + body, + require_comment=args.require_comment, + ) + if args.body_output is not None: + args.body_output.write_text(body + "\n", encoding="utf-8") + print(state, comment) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_receipt.py b/scripts/release_receipt.py new file mode 100644 index 0000000..7981214 --- /dev/null +++ b/scripts/release_receipt.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Build and validate the deterministic stable-release delivery receipt.""" + +from __future__ import annotations + +import argparse +import json +import re +import tomllib +from pathlib import Path +from typing import Any + +SCHEMA = "ocr-toolkit.release-receipt/v1" +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +HASH_RE = re.compile(r"^[0-9a-f]{64}$") +VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)+$") + + +class ReceiptError(ValueError): + """Release evidence is incomplete or internally inconsistent.""" + + +def supported_python_minors(pyproject: dict[str, Any]) -> list[str]: + """Return the contiguous supported minor range from canonical project metadata.""" + + project = pyproject.get("project") + requires = project.get("requires-python") if isinstance(project, dict) else None + match = re.fullmatch(r">=3\.(\d+),<3\.(\d+)", str(requires or "")) + if match is None: + raise ReceiptError("requires-python must be a contiguous >=3.X,<3.Y range") + lower, upper = (int(value) for value in match.groups()) + if lower >= upper or upper - lower > 10: + raise ReceiptError("requires-python minor range is invalid") + return [f"3.{minor}" for minor in range(lower, upper)] + + +def load_hashes(path: Path) -> dict[str, str]: + """Load the exact two-distribution SHA-256 mapping.""" + + payload = json.loads(path.read_text(encoding="utf-8")) + if ( + not isinstance(payload, dict) + or len(payload) != 2 + or not all( + isinstance(name, str) and isinstance(digest, str) and HASH_RE.fullmatch(digest) + for name, digest in payload.items() + ) + ): + raise ReceiptError("artifact hashes must contain exactly two SHA-256 entries") + return dict(sorted(payload.items())) + + +def build_receipt( + *, + version: str, + tag: str, + release_pr: int, + issues: list[int], + base: str, + head: str, + merge: str, + tree: str, + run_id: int, + run_attempt: int, + authorized_at: str, + artifacts: dict[str, str], + python_minors: list[str], +) -> dict[str, Any]: + """Return one canonical receipt after all pre-Release gates succeeded.""" + + commits = {"base": base, "head": head, "merge": merge, "tree": tree} + if not VERSION_RE.fullmatch(version) or tag != f"v{version}": + raise ReceiptError("release version and tag are inconsistent") + if any(not COMMIT_RE.fullmatch(value) for value in commits.values()): + raise ReceiptError("release commit/tree identities must be full lowercase SHAs") + if ( + release_pr <= 0 + or run_id <= 0 + or run_attempt <= 0 + or issues != sorted(set(issues)) + or not issues + or any( + isinstance(issue, bool) or not isinstance(issue, int) or issue <= 0 for issue in issues + ) + ): + raise ReceiptError("release PR, run, or issue identity is invalid") + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", authorized_at): + raise ReceiptError("release authorization timestamp must be UTC") + expected_artifacts = { + f"open_code_review_toolkit-{version}-py3-none-any.whl", + f"open_code_review_toolkit-{version}.tar.gz", + } + if set(artifacts) != expected_artifacts or any( + not HASH_RE.fullmatch(digest) for digest in artifacts.values() + ): + raise ReceiptError("artifact hashes are invalid") + parsed_python: list[int] = [] + for value in python_minors: + match = re.fullmatch(r"3\.(\d+)", value) + if match is None: + raise ReceiptError("supported Python receipt is invalid") + parsed_python.append(int(match.group(1))) + if ( + not parsed_python + or parsed_python != sorted(set(parsed_python)) + or parsed_python != list(range(parsed_python[0], parsed_python[-1] + 1)) + ): + raise ReceiptError("supported Python receipt is invalid") + + return { + "schema_version": SCHEMA, + "version": version, + "tag": tag, + "release_pr": release_pr, + "issues": issues, + "reviewed": commits, + "workflow": {"run_id": run_id, "run_attempt": run_attempt}, + # The merged release PR is the human authorization event. Using that + # immutable GitHub timestamp keeps receipt creation deterministic on recovery. + "authorized_at": authorized_at, + "artifacts": artifacts, + "registries": { + "testpypi": {"artifacts": "verified", "provenance": "verified"}, + "pypi": {"artifacts": "verified", "provenance": "verified"}, + }, + "github": { + "artifact_attestations": "verified", + "annotated_tag_target": merge, + "release_assets": "pending_self_readback", + }, + "python_smoke": {minor: "verified" for minor in python_minors}, + } + + +def validate_receipt( + payload: dict[str, Any], + *, + version: str, + tag: str, + release_pr: int, + issues: list[int], + base: str, + head: str, + merge: str, + tree: str, + authorized_at: str, + artifacts: dict[str, str], + python_minors: list[str], +) -> None: + """Validate an immutable prior-run receipt against current recovery evidence.""" + + expected = build_receipt( + version=version, + tag=tag, + release_pr=release_pr, + issues=issues, + base=base, + head=head, + merge=merge, + tree=tree, + run_id=1, + run_attempt=1, + authorized_at=authorized_at, + artifacts=artifacts, + python_minors=python_minors, + ) + for key in ( + "schema_version", + "version", + "tag", + "release_pr", + "issues", + "reviewed", + "artifacts", + "registries", + "github", + "python_smoke", + ): + if payload.get(key) != expected[key]: + raise ReceiptError(f"existing release receipt field {key!r} does not match") + workflow = payload.get("workflow") + if not isinstance(workflow, dict): + raise ReceiptError("existing release receipt workflow identity is invalid") + run_id = workflow.get("run_id") + run_attempt = workflow.get("run_attempt") + if ( + isinstance(run_id, bool) + or not isinstance(run_id, int) + or run_id <= 0 + or isinstance(run_attempt, bool) + or not isinstance(run_attempt, int) + or run_attempt <= 0 + ): + raise ReceiptError("existing release receipt workflow identity is invalid") + if payload.get("authorized_at") != expected["authorized_at"]: + raise ReceiptError("existing release receipt authorization timestamp does not match") + + +def main() -> int: + """CLI entrypoint for the stable release workflow.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--validate-existing", type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--release-pr", required=True, type=int) + parser.add_argument("--issues", required=True) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + parser.add_argument("--merge", required=True) + parser.add_argument("--tree", required=True) + parser.add_argument("--run-id", type=int) + parser.add_argument("--run-attempt", type=int) + parser.add_argument("--authorized-at", required=True) + parser.add_argument("--hashes", required=True, type=Path) + parser.add_argument("--pyproject", default=Path("pyproject.toml"), type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + issues = [int(value) for value in args.issues.split(",") if value] + pyproject = tomllib.loads(args.pyproject.read_text(encoding="utf-8")) + common: dict[str, Any] = { + "version": args.version, + "tag": args.tag, + "release_pr": args.release_pr, + "issues": issues, + "base": args.base, + "head": args.head, + "merge": args.merge, + "tree": args.tree, + "authorized_at": args.authorized_at, + "artifacts": load_hashes(args.hashes), + "python_minors": supported_python_minors(pyproject), + } + if args.validate_existing is not None: + payload = json.loads(args.validate_existing.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ReceiptError("existing release receipt must be a JSON object") + validate_receipt(payload, **common) + return 0 + if args.run_id is None or args.run_attempt is None or args.output is None: + raise ReceiptError("receipt creation requires run identity and output") + receipt = build_receipt( + **common, + run_id=args.run_id, + run_attempt=args.run_attempt, + ) + args.output.write_text( + json.dumps(receipt, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/testpypi_preview.py b/scripts/testpypi_preview.py index ba4291d..8de7c1d 100644 --- a/scripts/testpypi_preview.py +++ b/scripts/testpypi_preview.py @@ -108,11 +108,17 @@ def artifact_manifest( version: str, expected_hashes: dict[str, str] | None = None, artifact_host: str = ARTIFACT_HOST, + provenance_host: str | None = None, ) -> list[dict[str, str]]: """Return validated immutable download metadata for one complete release.""" if artifact_host not in ALLOWED_ARTIFACT_HOSTS: raise PreviewError(f"unsupported artifact host: {artifact_host}") + expected_provenance_host = provenance_host or ( + "pypi.org" if artifact_host == PYPI_ARTIFACT_HOST else "test.pypi.org" + ) + if expected_provenance_host not in {"pypi.org", "test.pypi.org"}: + raise PreviewError(f"unsupported provenance host: {expected_provenance_host}") if expected_hashes is not None: _validate_local_hashes(version, expected_hashes) names = expected_filenames(version) @@ -128,15 +134,20 @@ def artifact_manifest( hashes = item.get("hashes") digest = hashes.get("sha256") if isinstance(hashes, dict) else None url = item.get("url") + provenance_url = item.get("provenance") if not isinstance(digest, str) or not SHA256_RE.fullmatch(digest): raise PreviewError(f"{filename} has no valid SHA-256 digest") if not isinstance(url, str): raise PreviewError(f"{filename} has no download URL") + if not isinstance(provenance_url, str): + raise PreviewError(f"{filename} has no provenance URL") parsed = urlsplit(url) + provenance = urlsplit(provenance_url) try: port = parsed.port + provenance_port = provenance.port except ValueError as exc: - raise PreviewError(f"{filename} has an invalid download URL") from exc + raise PreviewError(f"{filename} has an invalid registry URL") from exc if ( parsed.scheme != "https" or parsed.hostname != artifact_host @@ -148,11 +159,29 @@ def artifact_manifest( or Path(parsed.path).name != filename ): raise PreviewError(f"{filename} has an untrusted download URL") + expected_provenance_path = f"/integrity/{PACKAGE}/{version}/{filename}/provenance" + if ( + provenance.scheme != "https" + or provenance.hostname != expected_provenance_host + or provenance_port not in (None, 443) + or provenance.username is not None + or provenance.password is not None + or provenance.query + or provenance.fragment + or any(ord(character) <= 0x20 or ord(character) == 0x7F for character in provenance_url) + or provenance.path != expected_provenance_path + ): + raise PreviewError(f"{filename} has an untrusted provenance URL") if expected_hashes is not None and digest != expected_hashes[filename]: raise PreviewError(f"published SHA-256 mismatch for {filename}") if filename in manifest: raise PreviewError(f"duplicate TestPyPI artifact: {filename}") - manifest[filename] = {"filename": filename, "sha256": digest, "url": url} + manifest[filename] = { + "filename": filename, + "sha256": digest, + "url": url, + "provenance": provenance_url, + } if set(manifest) != set(names): raise PreviewError(f"TestPyPI release {version} is incomplete") diff --git a/scripts/verify_registry_artifacts.sh b/scripts/verify_registry_artifacts.sh index ebf799e..1c811d3 100755 --- a/scripts/verify_registry_artifacts.sh +++ b/scripts/verify_registry_artifacts.sh @@ -23,12 +23,14 @@ esac index=/tmp/${registry}-index.json manifest=/tmp/${registry}-artifact-manifest.json downloads=/tmp/${registry}-artifact-downloads.tsv +provenance_downloads=/tmp/${registry}-provenance-downloads.tsv destination=/tmp/${registry}-artifacts for attempt in 1 2 3 4 5; do if curl --fail --location --silent --show-error \ --retry 3 --retry-delay 2 --retry-connrefused \ --connect-timeout 10 --max-time 120 \ + --max-filesize 10485760 \ --proto '=https' --proto-redir '=https' \ --header 'Accept: application/vnd.pypi.simple.v1+json' \ "${index_url}" --output "${index}"; then @@ -59,6 +61,14 @@ from pathlib import Path for item in json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")): print(item["sha256"], item["url"], item["filename"], sep="\t") PY +python - "${manifest}" <<'PY' > "${provenance_downloads}" +import json +import sys +from pathlib import Path + +for item in json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")): + print(item["provenance"], item["filename"], sep="\t") +PY mkdir -p "${destination}" tab=$(printf '\t') @@ -66,11 +76,33 @@ while IFS="${tab}" read -r sha256 url filename; do curl --fail --location --silent --show-error \ --retry 3 --retry-delay 2 --retry-connrefused \ --connect-timeout 10 --max-time 120 \ + --max-filesize 10485760 \ --proto '=https' --proto-redir '=https' \ "${url}" --output "${destination}/${filename}" echo "${sha256} ${destination}/${filename}" | sha256sum --check --strict done < "${downloads}" +case "${registry}" in + testpypi) provenance_environment=testpypi-public-disclosure ;; + pypi) provenance_environment=pypi-production ;; +esac +while IFS="${tab}" read -r provenance_url filename; do + provenance_file="${destination}/${filename}.provenance.json" + curl --fail --location --silent --show-error \ + --retry 3 --retry-delay 2 --retry-connrefused \ + --connect-timeout 10 --max-time 120 \ + --max-filesize 1048576 \ + --proto '=https' --proto-redir '=https' \ + --header 'Accept: application/vnd.pypi.integrity.v1+json' \ + "${provenance_url}" --output "${provenance_file}" + python scripts/verify_registry_provenance.py \ + --payload "${provenance_file}" \ + --hashes "${hashes}" \ + --filename "${filename}" \ + --environment "${provenance_environment}" \ + --repository "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +done < "${provenance_downloads}" + python -m venv "/tmp/${registry}-wheel" "/tmp/${registry}-wheel/bin/pip" install --no-deps "${destination}"/*.whl "/tmp/${registry}-wheel/bin/ocr-ci" --help diff --git a/scripts/verify_registry_provenance.py b/scripts/verify_registry_provenance.py new file mode 100644 index 0000000..7fd450b --- /dev/null +++ b/scripts/verify_registry_provenance.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Validate PyPI Integrity API publisher and exact artifact subjects.""" + +from __future__ import annotations + +import argparse +import base64 +import json +from pathlib import Path +from typing import Any + + +class ProvenanceError(ValueError): + """Registry provenance is missing or does not match the release workflow.""" + + +def verify_provenance( + payload: dict[str, Any], + *, + filename: str, + digest: str, + environment: str, + repository: str, +) -> None: + """Require one GitHub publisher bundle with the exact publish subject.""" + + bundles = payload.get("attestation_bundles") + if payload.get("version") != 1 or not isinstance(bundles, list) or len(bundles) != 1: + raise ProvenanceError("integrity response must contain one version-1 bundle") + bundle = bundles[0] + publisher = bundle.get("publisher") if isinstance(bundle, dict) else None + expected_publisher = { + "kind": "GitHub", + "repository": repository, + "workflow": "release.yml", + "environment": environment, + } + if publisher != expected_publisher: + raise ProvenanceError("integrity publisher does not match the release workflow") + attestations = bundle.get("attestations") + if not isinstance(attestations, list) or not attestations: + raise ProvenanceError("integrity bundle has no attestations") + expected_subject = [{"name": filename, "digest": {"sha256": digest}}] + matched = False + for attestation in attestations: + envelope = attestation.get("envelope") if isinstance(attestation, dict) else None + statement = envelope.get("statement") if isinstance(envelope, dict) else None + if not isinstance(statement, str): + continue + try: + decoded = json.loads(base64.b64decode(statement, validate=True)) + except (ValueError, json.JSONDecodeError): + continue + if ( + isinstance(decoded, dict) + and decoded.get("_type") == "https://in-toto.io/Statement/v1" + and decoded.get("subject") == expected_subject + and decoded.get("predicateType") == "https://docs.pypi.org/attestations/publish/v1" + ): + matched = True + if not matched: + raise ProvenanceError("integrity bundle has no exact publish-attestation subject") + + +def main() -> int: + """CLI entrypoint used after bounded registry provenance downloads.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--payload", required=True, type=Path) + parser.add_argument("--hashes", required=True, type=Path) + parser.add_argument("--filename", required=True) + parser.add_argument("--environment", required=True) + parser.add_argument("--repository", required=True) + args = parser.parse_args() + payload = json.loads(args.payload.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ProvenanceError("integrity response must be a JSON object") + hashes = json.loads(args.hashes.read_text(encoding="utf-8")) + if not isinstance(hashes, dict) or args.filename not in hashes: + raise ProvenanceError("integrity subject is not in the reviewed artifact set") + digest = hashes[args.filename] + if not isinstance(digest, str): + raise ProvenanceError("integrity subject digest is invalid") + verify_provenance( + payload, + filename=args.filename, + digest=digest, + environment=args.environment, + repository=args.repository, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_authorization.py b/tests/test_release_authorization.py index f3da3ca..302f9b9 100644 --- a/tests/test_release_authorization.py +++ b/tests/test_release_authorization.py @@ -2,15 +2,19 @@ from __future__ import annotations +import base64 import importlib.util +import json from copy import deepcopy from pathlib import Path from types import ModuleType +from typing import Any import pytest SCRIPT = Path(__file__).parents[1] / "scripts" / "release_authorization.py" WORKFLOW = Path(__file__).parents[1] / ".github" / "workflows" / "release.yml" +BOUNDED_API = Path(__file__).parents[1] / "scripts" / "bounded_github_api.sh" def load_script() -> ModuleType: @@ -24,33 +28,137 @@ def load_script() -> ModuleType: release = load_script() -def release_pr() -> dict[str, object]: +def release_pr() -> dict[str, Any]: return { "number": 5, "merged": True, "merged_at": "2026-07-20T10:00:00Z", "merge_commit_sha": "a" * 40, "title": "Release v0.1.0", - "base": {"ref": "main"}, + "base": {"ref": "main", "sha": "b" * 40}, "head": { "ref": "release/v0.1.0", + "sha": "c" * 40, "repo": {"full_name": "example/open-code-review-toolkit"}, }, } -def test_authorizes_exact_same_repository_release_merge() -> None: - outputs = release.authorize_release( - release_pr(), +def commit_payload(sha: str, tree: str, parents: list[str]) -> dict[str, Any]: + return { + "sha": sha, + "commit": {"tree": {"sha": tree}}, + "parents": [{"sha": parent} for parent in parents], + } + + +def ruleset() -> dict[str, Any]: + return { + "rules": [ + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": True, + "required_status_checks": [ + {"context": "quality", "integration_id": 15368}, + {"context": "CodeQL", "integration_id": 57789}, + ], + }, + } + ], + } + + +def check_runs() -> dict[str, Any]: + return { + "total_count": 2, + "check_runs": [ + { + "name": "quality", + "conclusion": "success", + "status": "completed", + "head_sha": "c" * 40, + "completed_at": "2026-08-10T10:00:00Z", + "app": {"id": 15368}, + }, + { + "name": "CodeQL", + "conclusion": "success", + "status": "completed", + "head_sha": "c" * 40, + "completed_at": "2026-08-10T10:01:00Z", + "app": {"id": 57789}, + }, + ], + } + + +def release_metadata() -> dict[str, Any]: + return { + "schema_version": "ocr-toolkit.release-authorization/v1", + "version": "0.1.0", + "issues": [70, 71], + } + + +def release_metadata_contents(metadata: dict[str, Any] | None = None) -> dict[str, Any]: + """Return one bounded GitHub Contents API response.""" + + raw = json.dumps(release_metadata() if metadata is None else metadata).encode() + return { + "type": "file", + "name": ".release-metadata.json", + "path": ".release-metadata.json", + "encoding": "base64", + "size": len(raw), + "content": base64.b64encode(raw).decode(), + } + + +def authorize( + payload: dict[str, Any] | None = None, + *, + head: dict[str, Any] | None = None, + merge: dict[str, Any] | None = None, + checks: dict[str, Any] | None = None, + protection: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + requested_version: str = "", + requested_commit: str = "", + requested_head: str = "", +) -> dict[str, str]: + """Call authorization with fully valid synthetic GitHub evidence by default.""" + + return release.authorize_release( + payload or release_pr(), "example/open-code-review-toolkit", + head or commit_payload("c" * 40, "d" * 40, ["e" * 40]), + merge or commit_payload("a" * 40, "d" * 40, ["b" * 40]), + checks or check_runs(), + protection or ruleset(), + release_metadata_contents(metadata), + requested_version, + requested_commit, + requested_head, + ) + + +def test_authorizes_exact_same_repository_release_merge() -> None: + outputs = authorize( requested_version="0.1.0", requested_commit="a" * 40, + requested_head="c" * 40, ) assert outputs == { "approved": "true", "branch": "release/v0.1.0", "commit": "a" * 40, + "base": "b" * 40, + "head": "c" * 40, + "tree": "d" * 40, + "issues": "70,71", + "merged-at": "2026-07-20T10:00:00Z", "pr-number": "5", "title": "Release v0.1.0", "version": "0.1.0", @@ -76,20 +184,134 @@ def test_rejects_mismatched_release_metadata(path: tuple[str, ...], value: objec target[path[-1]] = value # type: ignore[index] with pytest.raises(release.AuthorizationError): - release.authorize_release(payload, "example/open-code-review-toolkit") + authorize(payload) def test_recovery_inputs_must_match_the_merged_pr() -> None: with pytest.raises(release.AuthorizationError, match="requested version"): + authorize(requested_version="0.2.0") + with pytest.raises(release.AuthorizationError, match="requested commit"): + authorize(requested_commit="b" * 40) + with pytest.raises(release.AuthorizationError, match="requested head"): + authorize(requested_head="b" * 40) + + +def test_rejects_tree_parent_and_commit_identity_mismatches() -> None: + with pytest.raises(release.AuthorizationError, match="merge tree"): + authorize(merge=commit_payload("a" * 40, "f" * 40, ["b" * 40])) + with pytest.raises(release.AuthorizationError, match="merge parent"): + authorize(merge=commit_payload("a" * 40, "d" * 40, ["e" * 40])) + with pytest.raises(release.AuthorizationError, match="head commit response"): + authorize(head=commit_payload("f" * 40, "d" * 40, [])) + + +@pytest.mark.parametrize("issues", [[], [71, 70], [70, 70], [70, "71"], [0, 71]]) +def test_release_issue_set_is_tracked_unique_and_sorted(issues: list[object]) -> None: + metadata = release_metadata() + metadata["issues"] = issues + + with pytest.raises(release.AuthorizationError, match="issues"): + authorize(metadata=metadata) + + +def test_release_metadata_must_come_from_one_exact_bounded_contents_response() -> None: + contents = release_metadata_contents() + contents["size"] = contents["size"] + 1 + with pytest.raises(release.AuthorizationError, match="size does not match"): release.authorize_release( - release_pr(), "example/open-code-review-toolkit", requested_version="0.2.0" + release_pr(), + "example/open-code-review-toolkit", + commit_payload("c" * 40, "d" * 40, ["e" * 40]), + commit_payload("a" * 40, "d" * 40, ["b" * 40]), + check_runs(), + ruleset(), + contents, ) - with pytest.raises(release.AuthorizationError, match="requested commit"): + + contents = release_metadata_contents() + content = contents["content"] + contents["content"] = f"{content[:8]}\n{content[8:]}" + release.authorize_release( + release_pr(), + "example/open-code-review-toolkit", + commit_payload("c" * 40, "d" * 40, ["e" * 40]), + commit_payload("a" * 40, "d" * 40, ["b" * 40]), + check_runs(), + ruleset(), + contents, + ) + + contents = release_metadata_contents() + contents["content"] = "not-base64" + with pytest.raises(release.AuthorizationError, match="contents response is malformed"): release.authorize_release( - release_pr(), "example/open-code-review-toolkit", requested_commit="b" * 40 + release_pr(), + "example/open-code-review-toolkit", + commit_payload("c" * 40, "d" * 40, ["e" * 40]), + commit_payload("a" * 40, "d" * 40, ["b" * 40]), + check_runs(), + ruleset(), + contents, ) +def test_required_checks_must_succeed_from_the_exact_ruleset_app() -> None: + missing = check_runs() + missing["check_runs"] = list(missing["check_runs"])[:1] + missing["total_count"] = 1 + with pytest.raises(release.AuthorizationError, match="missing required checks"): + authorize(checks=missing) + + failed = check_runs() + failed["check_runs"][0]["conclusion"] = "failure" + with pytest.raises(release.AuthorizationError, match="unsuccessful required check"): + authorize(checks=failed) + + wrong_app = check_runs() + wrong_app["check_runs"][0]["app"]["id"] = 999 + with pytest.raises(release.AuthorizationError, match="missing required checks"): + authorize(checks=wrong_app) + + +def test_duplicate_or_incomplete_latest_check_response_fails_closed() -> None: + checks = check_runs() + checks["check_runs"].append( + { + "name": "quality", + "conclusion": "success", + "status": "completed", + "head_sha": "c" * 40, + "completed_at": "2026-08-10T10:02:00Z", + "app": {"id": 15368}, + } + ) + checks["total_count"] = 3 + with pytest.raises(release.AuthorizationError, match="duplicate required check"): + authorize(checks=checks) + + checks = check_runs() + checks["total_count"] = 101 + with pytest.raises(release.AuthorizationError, match="response is malformed"): + authorize(checks=checks) + + +def test_required_check_and_ruleset_synchronization_are_exact() -> None: + checks = check_runs() + checks["check_runs"][0]["head_sha"] = "f" * 40 + with pytest.raises(release.AuthorizationError, match="not bound"): + authorize(checks=checks) + + checks = check_runs() + checks["check_runs"][0]["status"] = "in_progress" + with pytest.raises(release.AuthorizationError, match="unsuccessful"): + authorize(checks=checks) + + protection = ruleset() + protection["rules"][0]["parameters"]["strict_required_status_checks_policy"] = False + with pytest.raises(release.AuthorizationError, match="strict base"): + authorize(protection=protection) + + def test_release_workflow_classifies_ordinary_merges_before_authorization() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") @@ -103,5 +325,25 @@ def test_release_workflow_keeps_strict_release_authorization() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") assert "python scripts/release_authorization.py" in workflow + assert "github.event.pull_request.merge_commit_sha || inputs['merge-commit']" in workflow assert 'test "${RELEASE_BRANCH}" = "release/v${VERSION}"' in workflow + assert "repos/${REPOSITORY}/rules/branches/main" in workflow + assert "commits/${HEAD_SHA}/check-runs?filter=latest&per_page=100" in workflow + assert "scripts/bounded_github_api.sh" in workflow + assert "max_bytes=${5:-1048576}" in BOUNDED_API.read_text(encoding="utf-8") + assert '--max-filesize "${max_bytes}"' in BOUNDED_API.read_text(encoding="utf-8") + assert "application/octet-stream" in BOUNDED_API.read_text(encoding="utf-8") + assert "--proto-redir '=https'" in BOUNDED_API.read_text(encoding="utf-8") + assert "Reading them\n # anonymously" in workflow + assert "--head-commit-json" in workflow + assert "--merge-commit-json" in workflow + assert "--check-runs-json" in workflow + assert "--ruleset-json" in workflow + assert "contents/.release-metadata.json?ref=${MERGE_SHA}" in workflow + assert "--release-metadata-contents-json /tmp/release-metadata-contents.json" in workflow + assert "--requested-head" in workflow + assert "Validate tracked release issues before publication" in workflow + assert "--validate-issue-only" in workflow + assert 'test "$(git rev-parse HEAD^{tree})" = "${EXPECTED_TREE}"' in workflow + assert 'test "$(git rev-parse FETCH_HEAD^{tree})" = "${EXPECTED_TREE}"' in workflow assert "github.event.pull_request.merged == true" not in workflow diff --git a/tests/test_release_process_docs.py b/tests/test_release_process_docs.py index d168ab9..5673c42 100644 --- a/tests/test_release_process_docs.py +++ b/tests/test_release_process_docs.py @@ -45,9 +45,12 @@ def step_with(*terms: str) -> int: step_with(".devN", "TestPyPI"), step_with("release/vX.Y.Z"), step_with("stable", "PyPI"), - step_with("no-release", "closure"), + step_with("release-receipt.json", "without another repository"), ) assert ordered_boundaries == tuple(sorted(ordered_boundaries)) + assert "final repository mutation" in release_required + assert "must not claim" in release_required + assert "no-release closure" not in release_required def test_boundary_guidance_has_one_authoritative_instruction_stack() -> None: diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py new file mode 100644 index 0000000..ce8b9a2 --- /dev/null +++ b/tests/test_release_receipt.py @@ -0,0 +1,321 @@ +"""Contracts for deterministic stable-release delivery receipts.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +ROOT = Path(__file__).parents[1] +SCRIPT = ROOT / "scripts" / "release_receipt.py" +PROVENANCE_SCRIPT = ROOT / "scripts" / "verify_registry_provenance.py" +ISSUE_RECEIPT_SCRIPT = ROOT / "scripts" / "release_issue_receipt.py" +WORKFLOW = ROOT / ".github" / "workflows" / "release.yml" + + +def load_script(path: Path, name: str) -> ModuleType: + """Load one repository script without depending on the working directory.""" + + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +receipt = load_script(SCRIPT, "release_receipt_script") +provenance = load_script(PROVENANCE_SCRIPT, "verify_registry_provenance_script") +issue_receipt = load_script(ISSUE_RECEIPT_SCRIPT, "release_issue_receipt_script") + + +def build_receipt(**overrides: Any) -> dict[str, Any]: + """Build one fully valid synthetic receipt by default.""" + + values: dict[str, Any] = { + "version": "0.4.7", + "tag": "v0.4.7", + "release_pr": 73, + "issues": [70, 71], + "base": "a" * 40, + "head": "b" * 40, + "merge": "c" * 40, + "tree": "d" * 40, + "run_id": 123, + "run_attempt": 1, + "authorized_at": "2026-08-10T10:00:00Z", + "artifacts": { + "open_code_review_toolkit-0.4.7.tar.gz": "e" * 64, + "open_code_review_toolkit-0.4.7-py3-none-any.whl": "f" * 64, + }, + "python_minors": ["3.12", "3.13", "3.14"], + } + values.update(overrides) + return receipt.build_receipt(**values) + + +def test_receipt_is_canonical_and_records_only_completed_pre_release_gates() -> None: + payload = build_receipt() + + assert payload["schema_version"] == "ocr-toolkit.release-receipt/v1" + assert payload["issues"] == [70, 71] + assert payload["reviewed"] == { + "base": "a" * 40, + "head": "b" * 40, + "merge": "c" * 40, + "tree": "d" * 40, + } + assert payload["registries"] == { + "testpypi": {"artifacts": "verified", "provenance": "verified"}, + "pypi": {"artifacts": "verified", "provenance": "verified"}, + } + assert payload["python_smoke"] == { + "3.12": "verified", + "3.13": "verified", + "3.14": "verified", + } + assert payload["github"]["release_assets"] == "pending_self_readback" + assert "immutable" not in json.dumps(payload).casefold() + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("tag", "v0.4.8"), + ("issues", [71, 70]), + ("issues", [70, 70]), + ("head", "not-a-sha"), + ("authorized_at", "now"), + ("python_minors", ["3.12", "3.14"]), + ], +) +def test_receipt_rejects_inconsistent_evidence(field: str, value: object) -> None: + with pytest.raises(receipt.ReceiptError): + build_receipt(**{field: value}) + + +def test_supported_python_minors_come_from_canonical_project_metadata() -> None: + assert receipt.supported_python_minors({"project": {"requires-python": ">=3.12,<3.15"}}) == [ + "3.12", + "3.13", + "3.14", + ] + with pytest.raises(receipt.ReceiptError): + receipt.supported_python_minors({"project": {"requires-python": ">=3.12"}}) + + +def test_existing_receipt_recovery_ignores_new_run_but_rejects_delivery_drift() -> None: + payload = build_receipt(run_id=555, run_attempt=3) + receipt.validate_receipt( + payload, + version="0.4.7", + tag="v0.4.7", + release_pr=73, + issues=[70, 71], + base="a" * 40, + head="b" * 40, + merge="c" * 40, + tree="d" * 40, + authorized_at="2026-08-10T10:00:00Z", + artifacts={ + "open_code_review_toolkit-0.4.7.tar.gz": "e" * 64, + "open_code_review_toolkit-0.4.7-py3-none-any.whl": "f" * 64, + }, + python_minors=["3.12", "3.13", "3.14"], + ) + + payload["issues"] = [70] + with pytest.raises(receipt.ReceiptError, match="issues"): + receipt.validate_receipt( + payload, + version="0.4.7", + tag="v0.4.7", + release_pr=73, + issues=[70, 71], + base="a" * 40, + head="b" * 40, + merge="c" * 40, + tree="d" * 40, + authorized_at="2026-08-10T10:00:00Z", + artifacts={ + "open_code_review_toolkit-0.4.7.tar.gz": "e" * 64, + "open_code_review_toolkit-0.4.7-py3-none-any.whl": "f" * 64, + }, + python_minors=["3.12", "3.13", "3.14"], + ) + + +def encoded_statement(filename: str, digest: str) -> str: + """Return one base64 PyPI publish-attestation statement.""" + + import base64 + + statement = { + "_type": "https://in-toto.io/Statement/v1", + "subject": [{"name": filename, "digest": {"sha256": digest}}], + "predicateType": "https://docs.pypi.org/attestations/publish/v1", + "predicate": None, + } + return base64.b64encode(json.dumps(statement).encode()).decode() + + +def integrity_payload(filename: str, digest: str) -> dict[str, Any]: + """Return one minimal registry-authoritative integrity response.""" + + return { + "version": 1, + "attestation_bundles": [ + { + "publisher": { + "kind": "GitHub", + "repository": "synthetic/open-code-review-toolkit", + "workflow": "release.yml", + "environment": "pypi-production", + }, + "attestations": [{"envelope": {"statement": encoded_statement(filename, digest)}}], + } + ], + } + + +def test_registry_provenance_requires_exact_publisher_and_subject() -> None: + filename = "package.whl" + digest = "a" * 64 + payload = integrity_payload(filename, digest) + + provenance.verify_provenance( + payload, + filename=filename, + digest=digest, + environment="pypi-production", + repository="synthetic/open-code-review-toolkit", + ) + + payload["attestation_bundles"][0]["publisher"]["environment"] = "other" + with pytest.raises(provenance.ProvenanceError, match="publisher"): + provenance.verify_provenance( + payload, + filename=filename, + digest=digest, + environment="pypi-production", + repository="synthetic/open-code-review-toolkit", + ) + + +def test_registry_provenance_rejects_wrong_digest_and_malformed_statement() -> None: + filename = "package.whl" + payload = integrity_payload(filename, "b" * 64) + with pytest.raises(provenance.ProvenanceError, match="subject"): + provenance.verify_provenance( + payload, + filename=filename, + digest="a" * 64, + environment="pypi-production", + repository="synthetic/open-code-review-toolkit", + ) + + payload["attestation_bundles"][0]["attestations"][0]["envelope"]["statement"] = "bad" + with pytest.raises(provenance.ProvenanceError, match="subject"): + provenance.verify_provenance( + payload, + filename=filename, + digest="a" * 64, + environment="pypi-production", + repository="synthetic/open-code-review-toolkit", + ) + + +def test_issue_receipt_accepts_only_exact_actions_owned_comment_and_closed_state() -> None: + body = issue_receipt.receipt_body("0.4.7", 70, "a" * 64) + comment = { + "body": body, + "user": {"login": "github-actions[bot]", "id": 41898282, "type": "Bot"}, + } + + assert ( + issue_receipt.issue_state( + {"number": 70, "state": "open", "state_reason": None}, 70, require_closed=False + ) + == "open" + ) + assert ( + issue_receipt.issue_state( + {"number": 70, "state": "closed", "state_reason": "completed"}, + 70, + require_closed=True, + ) + == "closed" + ) + assert issue_receipt.comment_state([comment], body, require_comment=True) == "matched" + + forged = {**comment, "user": {"login": "synthetic-user", "id": 7, "type": "User"}} + with pytest.raises(issue_receipt.IssueReceiptError, match="not uniquely owned"): + issue_receipt.comment_state([forged], body, require_comment=False) + with pytest.raises(issue_receipt.IssueReceiptError, match="not uniquely owned"): + issue_receipt.comment_state([forged], body, require_comment=True) + + +def test_issue_receipt_rejects_duplicate_wrong_body_or_incompatible_issue_state() -> None: + body = issue_receipt.receipt_body("0.4.7", 71, "b" * 64) + comment = { + "body": body, + "user": {"login": "github-actions[bot]", "id": 41898282, "type": "Bot"}, + } + with pytest.raises(issue_receipt.IssueReceiptError, match="not uniquely owned"): + issue_receipt.comment_state([comment, comment], body, require_comment=True) + with pytest.raises(issue_receipt.IssueReceiptError, match="body does not match"): + issue_receipt.comment_state( + [{**comment, "body": body + " changed"}], body, require_comment=True + ) + with pytest.raises(issue_receipt.IssueReceiptError, match="incompatible state"): + issue_receipt.issue_state( + {"number": 71, "state": "closed", "state_reason": "not_planned"}, + 71, + require_closed=False, + ) + with pytest.raises(issue_receipt.IssueReceiptError, match="response is invalid"): + issue_receipt.issue_state( + {"number": 71, "state": "open", "state_reason": None, "pull_request": {}}, + 71, + require_closed=False, + ) + + +def test_release_workflow_builds_reads_back_and_recovers_the_receipt() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert workflow.count('python: ["3.12", "3.13", "3.14"]') == 2 + assert "verify_registry_provenance.py" in ( + ROOT / "scripts" / "verify_registry_artifacts.sh" + ).read_text(encoding="utf-8") + assert "python scripts/release_receipt.py" in workflow + assert 'release upload "${TAG}" "${asset}"' in workflow + assert 'release upload "${TAG}" dist/*' not in workflow + assert "release upload" in workflow + assert "--clobber" not in workflow + assert "bounded_release_download" in workflow + assert "releases/assets/${asset_id}" in workflow + assert "application/octet-stream" in workflow + assert "gh release download" not in workflow + assert "duplicate GitHub Release asset" in workflow + assert '--validate-existing "${release_dir}/release-receipt.json"' in workflow + assert 'cmp release-receipt.json "${release_dir}/release-receipt.json"' in workflow + assert "GITHUB_API_VERSION=2026-03-10" in workflow + assert ".immutable == true" in workflow + assert "ocr-toolkit-release-receipt" in ISSUE_RECEIPT_SCRIPT.read_text(encoding="utf-8") + assert "has too many comments for bounded receipt lookup" in workflow + assert "cannot accept a receipt within the comment bound" in workflow + assert "python scripts/release_issue_receipt.py" in workflow + assert '--body-file "/tmp/issue-${issue}-receipt.md"' in workflow + assert "release receipt comment is not uniquely owned by GitHub Actions" in ( + ISSUE_RECEIPT_SCRIPT.read_text(encoding="utf-8") + ) + assert 'if [ "${issue_state}" = open ]; then' in workflow + assert 'gh issue close "${issue}" --repo "${GITHUB_REPOSITORY}" --reason completed' in workflow + assert 'state == "closed" and reason == "completed"' in ISSUE_RECEIPT_SCRIPT.read_text( + encoding="utf-8" + ) + assert "reset_approvals" not in workflow diff --git a/tests/test_testpypi_preview.py b/tests/test_testpypi_preview.py index 6cbfa44..8ebb15a 100644 --- a/tests/test_testpypi_preview.py +++ b/tests/test_testpypi_preview.py @@ -5,6 +5,7 @@ import importlib.util from pathlib import Path from types import ModuleType +from typing import Any import pytest @@ -36,13 +37,17 @@ def expected_hashes(version: str) -> dict[str, str]: } -def payload(hashes: dict[str, str]) -> dict[str, object]: +def payload(hashes: dict[str, str]) -> dict[str, Any]: return { "files": [ { "filename": filename, "hashes": {"sha256": digest}, "url": f"https://test-files.pythonhosted.org/packages/synthetic/{filename}", + "provenance": ( + "https://test.pypi.org/integrity/open-code-review-toolkit/" + f"0.1.0a3/{filename}/provenance" + ), } for filename, digest in hashes.items() ] @@ -128,6 +133,7 @@ def test_artifact_manifest_accepts_only_complete_trusted_release() -> None: production = payload(hashes) for item in production["files"]: item["url"] = item["url"].replace("test-files.pythonhosted.org", "files.pythonhosted.org") + item["provenance"] = item["provenance"].replace("test.pypi.org", "pypi.org") assert ( len(preview.artifact_manifest(production, "0.1.0a3", hashes, preview.PYPI_ARTIFACT_HOST)) == 2 @@ -137,6 +143,22 @@ def test_artifact_manifest_accepts_only_complete_trusted_release() -> None: with pytest.raises(preview.PreviewError): preview.artifact_manifest(production, "0.1.0a3", wrong_hashes, preview.PYPI_ARTIFACT_HOST) + forged_provenance = payload(hashes) + forged_provenance["files"][0]["provenance"] = ( + "https://test.pypi.org/integrity/open-code-review-toolkit/0.1.0a30/" + "open_code_review_toolkit-0.1.0a3-py3-none-any.whl/provenance" + ) + with pytest.raises(preview.PreviewError, match="provenance URL"): + preview.artifact_manifest(forged_provenance, "0.1.0a3") + + malformed_provenance = payload(hashes) + malformed_provenance["files"][0]["provenance"] = ( + "https://test.pypi.org:invalid/integrity/open-code-review-toolkit/" + "0.1.0a3/open_code_review_toolkit-0.1.0a3-py3-none-any.whl/provenance" + ) + with pytest.raises(preview.PreviewError, match="invalid registry URL"): + preview.artifact_manifest(malformed_provenance, "0.1.0a3") + def test_workflow_automates_one_idempotent_development_build_per_main_run() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") @@ -190,14 +212,21 @@ def test_production_release_verifies_reviewed_registry_artifacts() -> None: assert "SOURCE_DATE_EPOCH" in workflow assert "attestations: true" in workflow assert workflow.count("verify_registry_artifacts.sh") == 2 + assert workflow.count('python: ["3.12", "3.13", "3.14"]') == 2 assert "release_exists=false" in workflow - assert "release_is_draft=$(gh release view" in workflow + assert "authenticated 200,404" in workflow + assert "release_is_draft=$(jq -r .draft" in workflow assert "existing GitHub Release metadata does not match" in workflow assert workflow.count("timeout-minutes:") == 8 + assert workflow.count("--max-filesize 10485760") >= 1 assert "--retry 3 --retry-delay 2 --retry-connrefused" in verifier assert "--connect-timeout 10 --max-time 120" in verifier + assert verifier.count("--max-filesize 10485760") == 2 + assert "--max-filesize 1048576" in verifier assert "--proto '=https' --proto-redir '=https'" in verifier assert "sha256sum --check --strict" in verifier + assert "verify_registry_provenance.py" in verifier + assert "application/vnd.pypi.integrity.v1+json" in verifier assert '"${destination}"/*.whl' in verifier assert "scripts/install_local_artifact.py" in verifier assert "--require-hashes" in (PROJECT_ROOT / "scripts/install_local_artifact.py").read_text( From 4f3dd2994150d04629bd9198c828762dc3347282 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:05:28 +0200 Subject: [PATCH 5/7] Qualify Open Code Review 1.9.1 --- PLANS.md | 90 ++++++++++-- README.md | 2 +- changelog.d/72.feature.md | 1 + changelog.d/73.rules.md | 1 + compatibility/evidence/ocr-1.9.0.json | 98 +++++++++++++ compatibility/evidence/ocr-1.9.1.json | 97 +++++++++++++ compatibility/ocr-support.json | 106 +++++++++++++- docs/codex/TASKS_BACKLOG.md | 11 +- docs/compatibility.md | 6 +- docs/configuration.md | 2 +- docs/gitlab.md | 2 +- docs/security.md | 2 +- examples/gitlab/ocr-review.gitlab-ci.yml | 4 +- scripts/ocr_compat.py | 145 ++++++++++++++----- src/ocr_toolkit/preflight.py | 2 +- tests/test_evidence_mcp.py | 6 +- tests/test_integration_contracts.py | 24 +++- tests/test_ocr_compat.py | 168 +++++++++++++++++++++-- tests/test_runtime_helpers.py | 2 +- 19 files changed, 682 insertions(+), 87 deletions(-) create mode 100644 changelog.d/72.feature.md create mode 100644 changelog.d/73.rules.md create mode 100644 compatibility/evidence/ocr-1.9.0.json create mode 100644 compatibility/evidence/ocr-1.9.1.json diff --git a/PLANS.md b/PLANS.md index d8d8d5f..761ca3d 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,12 +4,12 @@ Use this file for active, blocked, or recently completed execution work. Update ## Active Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7 -Status: active; issues #70/#71 and release-lifecycle checkpoints complete +Status: active; implementation, release-lifecycle, and OCR 1.9.1 qualification checkpoints complete Owner: Codex -Last Updated: 2026-08-10 +Last Updated: 2026-08-11 Release Classification: release-required Target Stable Version: 0.4.7 -Tracking Issues: #70, #71 +Tracking Issues: #70, #71, #72 (OCR 1.9.1), #73 (OCR 1.9.0) ### Goal @@ -34,13 +34,16 @@ independently read back. An invalid value disables approval for that run. Encode the initial policy in code and fail closed when authoritative completeness or typed finding metadata cannot be proven. -- After the release-lifecycle checkpoint, qualify Open Code Review 1.9.0 from - authoritative release/source evidence. Classify every upstream item as a - toolkit-consumed contract change, future-backlog impact, or explicit no - impact; adapt only demonstrated contracts and atomically replace the local - checksum-pinned OCR binary before full E2E. +- After the release-lifecycle checkpoint, qualify the contiguous Open Code + Review 1.9.0 and 1.9.1 chain from authoritative release/source evidence. + Preserve a separate checksum/contract record and human impact conclusion for + each release, with a separate qualification issue for each version. Classify + every upstream item as a toolkit-consumed contract change, future-backlog + impact, or explicit no impact; adapt only demonstrated contracts and + atomically replace the local checksum-pinned OCR binary with 1.9.1 before + full E2E. - After the complete feature implementation is committed, run exactly one real - local OCR 1.9.0 review through `uv run ocr-ci review` over + local OCR 1.9.1 review through `uv run ocr-ci review` over `origin/main..HEAD`. Require the built-in `ocr_toolkit_evidence` MCP receipt, do not post to GitLab, fix actionable findings, and then use deterministic validation and self-review rather than a second OCR run. @@ -49,8 +52,9 @@ independently read back. - Redesign the durable release lifecycle so the release PR is the final repository mutation without preclaiming external facts. Bind publication to the exact reviewed tree and emit an immutable machine-readable release - receipt; close #70/#71 only after independent registry, provenance, tag, - Release, receipt, hash, and supported-Python readback succeeds. + receipt; close #70/#71 and both OCR qualification issues only after + independent registry, provenance, tag, Release, receipt, hash, and + supported-Python readback succeeds. ### Work Queue @@ -64,9 +68,10 @@ independently read back. release authorization and deterministic `ocr-toolkit.release-receipt/v1` evidence; update durable rules, recovery behavior, tests, and the lifecycle checkpoint commit. -4. [ ] Inspect authoritative OCR 1.9.0 release notes and source changes, record - consumed-contract/backlog/no-impact classifications, update compatibility - records and local checksum-pinned OCR, and adapt the toolkit only where +4. [x] Inspect authoritative OCR 1.9.0 and 1.9.1 release notes and source + changes, record separate consumed-contract/backlog/no-impact + classifications and qualification issues, update compatibility records and + the local checksum-pinned OCR 1.9.1 binary, and adapt the toolkit only where evidence requires it. 5. [ ] Reconcile this plan, roadmap table/diagram, backlog, and current-state documentation against the implemented code. Run focused tests, the synthetic @@ -87,7 +92,8 @@ independently read back. 10. [ ] Merge the release PR only after exact-head protected checks. Verify stable TestPyPI/PyPI artifacts, provenance/attestations, annotated tag, immutable GitHub Release and release receipt, hashes, and Python 3.12-3.14 installs. - Record receipts and close #70/#71 without another repository PR. + Record receipts and close #70/#71 plus both OCR qualification issues without + another repository PR. ### Initial Evidence @@ -194,6 +200,60 @@ independently read back. unchanged at this checkpoint because the lifecycle hardening changes process, not an outcome milestone or future-work activation trigger. +### OCR 1.9.0-1.9.1 Qualification Checkpoint + +- Canonical GitHub Actions run `31465539451` created separate open + qualification issues #73 for 1.9.0 and #72 for 1.9.1. Local Python 3.12 + qualification independently downloaded all seven assets for each release, + proved GitHub digests equal the upstream `sha256sum.txt`, executed the Linux + amd64 version/help/JSON-preview/full-review/result/posting contracts, and + reproduced both evidence files byte-for-byte from checkpoint `5acbf15`. +- OCR 1.9.0 is compatible after required human review. Toolkit-consumed changes + are JSON preview output, preview session-store isolation, additive private + comment `thinking`, merge-base range semantics, and the Nim rules/allowlist + expansion. The harness now proves JSON preview, no session-store creation, + additive `thinking` preservation, and non-publication of that private field; + source review confirms reasoning-content backfill and the documented range + semantics. The Nim change receives a separate `🧩 Rules` entry. +- OCR 1.9.0 per-file token limits and retry status codes are future profile or + configuration inputs only and do not activate BL-016. Mistral and MiniMax + providers, QCA delegation, the upstream GitLab example, Pages/viewer/CSP, + scan and installation documentation, fork deployment, blog, package-manager, + and other documentation fixes are not toolkit-owned contracts. They require + no runtime, roadmap, or backlog activation. +- OCR 1.9.1 is an adjacent automatic-safe patch whose source was still reviewed. + Viewer comment filters and suggestion-panel layout, CodeQL workflow + permissions, upstream contributor/retry documentation, and the Anthropic + dynamic cache breakpoint do not change toolkit CLI, result, posting, + configuration, or MCP contracts. The cache change is a future profile/quality + input only and does not complete BL-016 or BL-017. +- Both releases retain Go MCP SDK v1.6.1 and protocol revision `2025-11-25`, so + the built-in MCP protocol matrix is unchanged. Both annotated upstream tags + carry signatures that GitHub reports as `unknown_key`; compatibility does not + misrepresent them as verified and instead relies on the double-source asset + digest contract plus executed binary probes. +- Human-reviewed promotion now accepts only an adjacent patch, next minor `.0`, + or next major `.0.0`; every minor/major transition requires an explicit + bounded conclusion. The automatic lane remains patch-only. Self-review also + isolated Git initialization, preview, and full-review probes from operator + OCR/Git configuration and bounded optional automatic-safe conclusions. +- Manifest, preflight, public examples, documentation, tests, and Linux digest + now target OCR 1.9.1. The PATH-effective Darwin arm64 binary is official OCR + 1.9.1 with SHA-256 + `5cffe45ef006b80dcbe95e6711807261850108d6390ce708cdac0e72cb261d1d`; + its isolated local contract probe passes. Focused validation passes 265 tests + plus 27 subtests, Ruff, strict mypy, manifest validation, Towncrier 0.4.7 + draft, and `git diff --check`. +- Backlog statuses, roadmap table/diagram, and strategy status remain unchanged. + Nim is review-engine scope rather than an evidence pack; upstream `AGENTS.md` + is contributor guidance rather than target-ref runtime guidance; token/cache + changes do not supply the missing profile or telemetry policy contracts. +- The complete isolated Python 3.12.13 quality gate passes formatting, Ruff, + strict mypy, Bandit, 622 tests plus 81 subtests, and 79.61% coverage. A fresh + authenticated discovery after promotion reports zero unseen stable OCR + releases. The gate uses `.quality-logs/py312` and does not mutate the host + `.venv` or tracked checkout. + ## Completed Plan: Reconcile 0.4.6 lifecycle, architecture, and backlog truth Status: completed; validated documentation/process PR handoff diff --git a/README.md b/README.md index 889c97f..1a6c5f5 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ ocr --version ocr-ci --help ``` -The current compatibility target is OCR `1.8.10`. CI should pin the release and verify its published checksum before execution. +The current compatibility target is OCR `1.9.1`. CI should pin the release and verify its published checksum before execution. The [versioned compatibility policy](docs/compatibility.md) records tested assets and evidence and describes the conservative Dependabot-like qualification workflow for later upstream releases. Review output defaults to English. `OCR_REVIEW_LANGUAGE` accepts another explicit language name when a project needs localized review output; for example, `OCR_REVIEW_LANGUAGE=Russian`. diff --git a/changelog.d/72.feature.md b/changelog.d/72.feature.md new file mode 100644 index 0000000..267d650 --- /dev/null +++ b/changelog.d/72.feature.md @@ -0,0 +1 @@ +Target checksum-verified Open Code Review 1.9.1 after qualifying 1.9.0 through 1.9.1. diff --git a/changelog.d/73.rules.md b/changelog.d/73.rules.md new file mode 100644 index 0000000..97d7218 --- /dev/null +++ b/changelog.d/73.rules.md @@ -0,0 +1 @@ +The recommended OCR built-in rules and reviewable-file allowlist now include Nim source, script, and package files. diff --git a/compatibility/evidence/ocr-1.9.0.json b/compatibility/evidence/ocr-1.9.0.json new file mode 100644 index 0000000..ebab2eb --- /dev/null +++ b/compatibility/evidence/ocr-1.9.0.json @@ -0,0 +1,98 @@ +{ + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "9cf93a98b85ceb7aa1d66e50aec2ea52a90f245748bfc1c23dce30e69bf0dc8e", + "size": 47082448 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "7ca90c42b4ece4b7aa1f89890fc185e51e83b4548436cf4e8ef324e32eeaf56c", + "size": 44745954 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "4164682c1a6f1992f1f7271d7e1d42a98c2de37866483e4c1218bcc4f397f6e3", + "size": 45572258 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "278fecf6ea819ab56ff7ce0c23fd1a56684c8e32609693a4acdf78885c90ccb2", + "size": 42991778 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "a0c2467edb34a017ad4aeeba828601dbee131e298214990dfd55eb6e216abb45", + "size": 46803456 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "360cd250e9af9fd9d0eafce5b75e8306481c3dcfacf29abc9a6f24db9f931e68", + "size": 43597824 + }, + { + "name": "sha256sum.txt", + "sha256": "b165dc13d2e7c25f542a211d056d12878c9e20b26f5c776bdfebf38cc4feaf83", + "size": 572 + } + ], + "classification": "human-review-required", + "classification_reasons": [ + "candidate is not a newer patch in the tested major/minor line", + "release notes contain a material or ambiguous compatibility signal" + ], + "comparison_version": "1.8.10", + "contracts": { + "comment_thinking_probe": { + "additive_field_preserved": true, + "posting_exposes_thinking": false, + "result": "passed" + }, + "optional_capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override" + ], + "preview_probe": { + "format": "json", + "path": "example.py", + "result": "passed", + "session_store_created": false + }, + "required_review_flags": [ + "--audience", + "--background-file", + "--format", + "--from", + "--preview", + "--rule", + "--to" + ], + "result_contract_probe": { + "additive_fields_allowed": true, + "comment_fields": [ + "category", + "content", + "end_line", + "existing_code", + "path", + "severity", + "start_line", + "thinking" + ], + "manifest_schema": "ocr.run-manifest/v1", + "normalized_outcome": "clean", + "result": "passed" + }, + "version_probe": "passed" + }, + "published_at": "2026-08-10T09:09:50Z", + "release_changes": "## 🚀 Features\n\n- feat(llm): support custom retry status codes via retry_codes config (#818)\n- feat(llmloop): backfill comment thinking from turn output (#773)\n- feat(pages): show live npm downloads in highlights stats (#794)\n- feat(allowlist): add Nim support (#798) (#799)\n- feat(llm): add built-in Mistral AI provider preset (#781)\n- feat(examples/gitlab): align GitLab CI review posting with the GitHub Action (#767)\n- feat: add QCA delegation integration (#762)\n- feat(config): make per-file token limit configurable (#716)\n\n## 🐛 Bug Fixes\n\n- fix: no pages deployment on forks (#793)\n- fix(cli): stop preview from creating a review session (#784)\n- fix(pages): keep previous page visible during lazy route transitions (#792)\n- fix(cli): honor --format json for review and scan preview (#783)\n- fix(llm): add MiniMax global provider (#760)\n- fix(viewer): externalize repos page inline script to comply with CSP (#758)\n\n## 📖 Documentation\n\n- docs: fix invalid subject (#795)\n- docs: remove stale `ocr session comments` from README and migrate to site docs (#774)\n- docs(blog): add trilingual OSS two-month retrospective post (#777)\n- docs(cli-reference): document ocr scan flags (#770)\n- docs: add instructions for macports (#769)\n- docs: clarify --from/--to merge-base semantics (#579) (#613)\n- docs(i18n): sync max_tokens configuration docs to ja, ru, zh (#766)\n- docs(installation): add Homebrew install method (#768)\n\n**Full Changelog**: https://github.com/alibaba/open-code-review/compare/v1.8.10...v1.9.0", + "release_notes_sha256": "2cf4c4f675747cd8cb1349d5a1bc0fdbc331837335a9f99e55f58a95b3f311b3", + "result": "compatible", + "schema_version": 2, + "tag": "v1.9.0", + "tested_baseline_version": "1.8.10", + "upstream_repository": "alibaba/open-code-review", + "version": "1.9.0" +} diff --git a/compatibility/evidence/ocr-1.9.1.json b/compatibility/evidence/ocr-1.9.1.json new file mode 100644 index 0000000..22e8b12 --- /dev/null +++ b/compatibility/evidence/ocr-1.9.1.json @@ -0,0 +1,97 @@ +{ + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "8ecf3e7c42ccd45e4f9c5eef6ccb84df9b718af734e122c221e6d1854616cac0", + "size": 47094912 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "5cffe45ef006b80dcbe95e6711807261850108d6390ce708cdac0e72cb261d1d", + "size": 44762642 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "9cb546e4f29389e3b7d768becc34a18cf2aaa6635610459fa65a7ea32a6c8bec", + "size": 45584546 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "ad949f5dcff8b6645c5c3d47d05b5812959bd10505c44ffe41c7d559a8fecaa3", + "size": 42991778 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "911cd8ba3780218728feb211f2712a364fce47b184b8282a1ceabdde0690d3eb", + "size": 46815744 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "49b291b06fbcd66461343d1ce7cdb54c5e2e6da799ca2daba8a7862ab1185220", + "size": 43609600 + }, + { + "name": "sha256sum.txt", + "sha256": "4d5b642bef116885d9d5d35b3d59a67bb9c0e6e1825e7201a149abeaa472d33c", + "size": 572 + } + ], + "classification": "automatic-safe", + "classification_reasons": [ + "same-minor patch passed all probes with maintenance-only notes" + ], + "comparison_version": "1.9.0", + "contracts": { + "comment_thinking_probe": { + "additive_field_preserved": true, + "posting_exposes_thinking": false, + "result": "passed" + }, + "optional_capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override" + ], + "preview_probe": { + "format": "json", + "path": "example.py", + "result": "passed", + "session_store_created": false + }, + "required_review_flags": [ + "--audience", + "--background-file", + "--format", + "--from", + "--preview", + "--rule", + "--to" + ], + "result_contract_probe": { + "additive_fields_allowed": true, + "comment_fields": [ + "category", + "content", + "end_line", + "existing_code", + "path", + "severity", + "start_line", + "thinking" + ], + "manifest_schema": "ocr.run-manifest/v1", + "normalized_outcome": "clean", + "result": "passed" + }, + "version_probe": "passed" + }, + "published_at": "2026-08-11T05:22:58Z", + "release_changes": "## 🚀 Features\n\n- feat(viewer): add review comment tag filters (#779)\n- feat(llm): add dynamic cache breakpoint on last message for Anthropic (#828)\n\n## 🐛 Bug Fixes\n\n- fix(codeql): Workflow does not contain permissions (#814)\n- fix(viewer): stack suggested code panels (#739)\n\n## 📖 Documentation\n\n- docs: 补充 retry_codes 配置文档 (#827)\n- docs: add AGENTS.md and track CLAUDE.md for shared agent guidelines (#826)\n\n**Full Changelog**: https://github.com/alibaba/open-code-review/compare/v1.9.0...v1.9.1", + "release_notes_sha256": "7c8ba0a31ff77603530a91555a690fe01a331271a487bb7ba3b32ed2b1cdf427", + "result": "compatible", + "schema_version": 2, + "tag": "v1.9.1", + "tested_baseline_version": "1.8.10", + "upstream_repository": "alibaba/open-code-review", + "version": "1.9.1" +} diff --git a/compatibility/ocr-support.json b/compatibility/ocr-support.json index 641ae77..bd78331 100644 --- a/compatibility/ocr-support.json +++ b/compatibility/ocr-support.json @@ -1,6 +1,6 @@ { - "monitoring_floor": "1.8.10", - "recommended_version": "1.8.10", + "monitoring_floor": "1.9.1", + "recommended_version": "1.9.1", "releases": [ { "assets": [ @@ -573,6 +573,108 @@ "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.8.10", "status": "tested", "version": "1.8.10" + }, + { + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "9cf93a98b85ceb7aa1d66e50aec2ea52a90f245748bfc1c23dce30e69bf0dc8e", + "size": 47082448 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "7ca90c42b4ece4b7aa1f89890fc185e51e83b4548436cf4e8ef324e32eeaf56c", + "size": 44745954 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "4164682c1a6f1992f1f7271d7e1d42a98c2de37866483e4c1218bcc4f397f6e3", + "size": 45572258 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "278fecf6ea819ab56ff7ce0c23fd1a56684c8e32609693a4acdf78885c90ccb2", + "size": 42991778 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "a0c2467edb34a017ad4aeeba828601dbee131e298214990dfd55eb6e216abb45", + "size": 46803456 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "360cd250e9af9fd9d0eafce5b75e8306481c3dcfacf29abc9a6f24db9f931e68", + "size": 43597824 + }, + { + "name": "sha256sum.txt", + "sha256": "b165dc13d2e7c25f542a211d056d12878c9e20b26f5c776bdfebf38cc4feaf83", + "size": 572 + } + ], + "capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override" + ], + "evidence": "compatibility/evidence/ocr-1.9.0.json", + "evidence_sha256": "c12368e80a0ad337c372c99a3dedc462838e11c13e15eb444e960bbc065885dd", + "human_conclusion": "Compatible after human review in issue #73 and workflow run 31465539451. JSON preview now honors --format json without creating a session store; additive comment thinking is preserved by OCR but remains private because toolkit posting does not render it. Nim built-in rules and .nim/.nims/.nimble allowlist entries extend the effective rules contract. Merge-base documentation confirms the toolkit range model. Per-file limits and retry codes are upstream configuration capabilities, while Mistral/MiniMax providers, QCA, upstream GitLab example, Pages/viewer, installation, scan documentation, and other documentation/CI changes are not toolkit-owned contracts. The Go MCP SDK remains v1.6.1.", + "published_at": "2026-08-10T09:09:50Z", + "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.9.0", + "status": "tested", + "version": "1.9.0" + }, + { + "assets": [ + { + "name": "opencodereview-darwin-amd64", + "sha256": "8ecf3e7c42ccd45e4f9c5eef6ccb84df9b718af734e122c221e6d1854616cac0", + "size": 47094912 + }, + { + "name": "opencodereview-darwin-arm64", + "sha256": "5cffe45ef006b80dcbe95e6711807261850108d6390ce708cdac0e72cb261d1d", + "size": 44762642 + }, + { + "name": "opencodereview-linux-amd64", + "sha256": "9cb546e4f29389e3b7d768becc34a18cf2aaa6635610459fa65a7ea32a6c8bec", + "size": 45584546 + }, + { + "name": "opencodereview-linux-arm64", + "sha256": "ad949f5dcff8b6645c5c3d47d05b5812959bd10505c44ffe41c7d559a8fecaa3", + "size": 42991778 + }, + { + "name": "opencodereview-windows-amd64.exe", + "sha256": "911cd8ba3780218728feb211f2712a364fce47b184b8282a1ceabdde0690d3eb", + "size": 46815744 + }, + { + "name": "opencodereview-windows-arm64.exe", + "sha256": "49b291b06fbcd66461343d1ce7cdb54c5e2e6da799ca2daba8a7862ab1185220", + "size": 43609600 + }, + { + "name": "sha256sum.txt", + "sha256": "4d5b642bef116885d9d5d35b3d59a67bb9c0e6e1825e7201a149abeaa472d33c", + "size": 572 + } + ], + "capabilities": [ + "llm_result_identity", + "per_run_model_override", + "per_run_provider_override" + ], + "evidence": "compatibility/evidence/ocr-1.9.1.json", + "evidence_sha256": "9a72bacb850c216ea3ae0d519841856194cc64ce2032a6f3805af4c51f2bbe34", + "human_conclusion": "Compatible after adjacent qualification in issue #72 and workflow run 31465539451. Viewer filtering/layout, CodeQL workflow permissions, upstream agent/retry documentation, and Anthropic cache-breakpoint optimization do not change toolkit-owned CLI, result, posting, configuration, or MCP contracts. JSON preview, full review, additive thinking, and toolkit result/posting consumer probes pass; the Go MCP SDK remains v1.6.1.", + "published_at": "2026-08-11T05:22:58Z", + "release_url": "https://github.com/alibaba/open-code-review/releases/tag/v1.9.1", + "status": "tested", + "version": "1.9.1" } ], "schema_version": 1, diff --git a/docs/codex/TASKS_BACKLOG.md b/docs/codex/TASKS_BACKLOG.md index 41e5fb8..8d62fbb 100644 --- a/docs/codex/TASKS_BACKLOG.md +++ b/docs/codex/TASKS_BACKLOG.md @@ -28,7 +28,7 @@ Statuses are `ready`, `planned`, `parked`, `conditional`, or `owner action`. Rel - **Exclusions:** Reworking implemented collectors without a gap, unused ecosystems, mutable runner inspection, package-registry queries, arbitrary build execution, or treating declarations as resolved versions. - **Validation:** Per-format source/target fixtures, conflict and limit cases, and common evidence-model contract tests. - **Release classification expectation:** `release-required` for new public evidence behavior; a format-selection audit alone is `no-release`. -- **Upstream overlap:** OCR file selection and generic rules do not supply repository evidence, resolution semantics, provenance, deltas, or scoped completeness. Upstream language support neither completes nor broadens this narrowed item by itself. +- **Upstream overlap:** OCR file selection and generic rules do not supply repository evidence, resolution semantics, provenance, deltas, or scoped completeness. OCR 1.9.0 adding Nim to its rules and file allowlist changes review-engine scope only; it neither completes nor broadens this narrowed item by itself. ### BL-009: Select and establish framework evidence plugins @@ -43,7 +43,7 @@ Statuses are `ready`, `planned`, `parked`, `conditional`, or `owner action`. Rel - **Exclusions:** Route/call/symbol graphs, framework-specific reviewers, or speculative detection without version evidence. - **Validation:** Positive/negative/multi-component fixtures, version-conflict and staleness cases, and plugin isolation tests. - **Release classification expectation:** `release-required`. -- **Upstream overlap:** OCR 1.8.8's built-in Nix/Haskell rules improve language review but do not identify frameworks, versions, component scope, provenance, or completeness. They do not satisfy the plugin selection trigger or any BL-009 acceptance criterion. +- **Upstream overlap:** OCR's built-in Nix, Haskell, and 1.9.0 Nim rules improve language review but do not identify frameworks, versions, component scope, provenance, or completeness. They do not satisfy the plugin selection trigger or any BL-009 acceptance criterion. ### BL-010: Add evidence packs from demonstrated use cases @@ -58,7 +58,7 @@ Statuses are `ready`, `planned`, `parked`, `conditional`, or `owner action`. Rel - **Exclusions:** Checkbox coverage, network resolution, runtime code execution, or bundles spanning unrelated ecosystems. - **Validation:** Pack-specific fixtures plus common evidence and bootstrap/MCP projection contracts. - **Release classification expectation:** `release-required`. -- **Upstream overlap:** Built-in OCR language allowlists and rules are review-engine capabilities, not toolkit evidence packs. OCR 1.8.8 creates no demonstrated missing-evidence use case and does not activate BL-010. +- **Upstream overlap:** Built-in OCR language allowlists and rules are review-engine capabilities, not toolkit evidence packs. OCR 1.8.8 Nix/Haskell and OCR 1.9.0 Nim support create no demonstrated missing-evidence use case and do not activate BL-010. ## M3 External MCP hardening @@ -134,6 +134,7 @@ Statuses are `ready`, `planned`, `parked`, `conditional`, or `owner action`. Rel - **Exclusions:** Removing safeguards before the trigger, copying full guidance into bootstrap, or toolkit-specific instruction execution. - **Validation:** Multi-scope target/source fixtures, changed-guidance attacks, capability fallback tests, and bootstrap budget tests. - **Release classification expectation:** `release-required`. +- **Upstream overlap:** OCR 1.9.1 adds repository-maintainer `AGENTS.md` guidance for upstream contributors, not a runtime target-ref-aware guidance discovery contract. The activation trigger remains unmet. ## M5 Review profiles and quality measurement @@ -146,7 +147,7 @@ Telemetry is intentionally outside M1. OCR owns token, cost, budget, provider-le - **Roadmap theme:** M5 Review profiles and quality measurement - **Dependencies:** The established M1 built-in MCP lifecycle and an OCR compatibility entry advertising per-run model/provider override capability. OCR 1.8.7 satisfies the upstream capability dependency. - **Activation trigger:** Profile model and limit differences can be documented without changing per-tool routing; the remaining trigger is an owner-approved closed profile matrix and precedence contract. -- **Upstream overlap:** OCR 1.8.10 makes tool-parameter rendering deterministic, which improves prompt and cache reproducibility for future profile comparisons. It does not define the toolkit's closed profile matrix, precedence, validation, or effective-configuration contract, so BL-016 remains planned. +- **Upstream overlap:** OCR 1.8.10 makes tool-parameter rendering deterministic, OCR 1.9.0 exposes a per-file token limit, and OCR 1.9.1 improves Anthropic cache breakpoints. These are useful profile inputs but do not define the toolkit's closed profile matrix, precedence, validation, or effective-configuration contract, so BL-016 remains planned. - **Goal:** Offer `economy`, `standard`, and `strong` choices for one OCR review run. - **Scoped deliverables:** Define explicit profile configuration selecting a run-level model and a documented closed set of existing OCR limits; map the profile to OCR's per-run override rather than mutating persistent OCR configuration; publish the effective profile and observed additive result identity without credentials; validate profile/model availability through optional capabilities in the compatibility contract, environment precedence, and rendered effective configuration. - **Acceptance criteria:** One model remains active per run, `standard` preserves current behavior, explicit per-setting environment values override profile defaults, secrets remain environment-only, and unavailable model/capability combinations or unsupported profiles fail before OCR execution. @@ -161,7 +162,7 @@ Telemetry is intentionally outside M1. OCR owns token, cost, budget, provider-le - **Roadmap theme:** M5 Review profiles and quality measurement - **Dependencies:** Established discussion/fingerprint lifecycle, structured OCR result normalization, review-health reporting, failed-file coverage, finding/posting receipts, and MCP-use attribution. BL-016 is required only for later comparisons between named profiles, not for the gap audit. - **Activation trigger:** Met for the audit: current OCR telemetry and toolkit result-derived receipts are sufficient to inventory available signals before any new telemetry layer is proposed. -- **Upstream overlap:** OCR 1.8.10's deterministic tool-parameter rendering reduces an upstream source of comparison noise. It adds no missing lifecycle, evidence, posting, or review-value measurement contract; the now-ready audit must first determine whether current upstream and result-derived signals already suffice. +- **Upstream overlap:** OCR 1.8.10's deterministic tool rendering and OCR 1.9.1's Anthropic cache optimization reduce upstream comparison or execution cost noise, but add no missing lifecycle, evidence, posting, or review-value measurement contract. The now-ready audit must first determine whether current upstream telemetry and result-derived signals already suffice. - **Goal:** Determine whether any privacy-safe toolkit telemetry is still necessary before implementing metrics or profile routing. - **Scoped deliverables:** Inventory OCR token, cost, budget, latency, request, tool-call, and provider/model identity alongside established review health, failed-file coverage, findings, suppression, omission, posting, and MCP-use receipts. Document only the remaining lifecycle, evidence degradation, repeated-discussion, compatibility, or review-value gaps. If no material gap remains, close the item without a runtime layer; any justified implementation becomes a separately scoped release-classified follow-up. - **Acceptance criteria:** The audit maps every available signal to its current authoritative source, distinguishes derived from genuinely missing data, records privacy/cardinality constraints for any gap, and reaches an explicit no-new-layer or separately scoped follow-up conclusion. OCR remains the source for token, cost, budget, request, latency, and tool-call telemetry; the audit itself adds no runtime, exporter, or public schema. diff --git a/docs/compatibility.md b/docs/compatibility.md index d9ff1c8..54614fb 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -6,9 +6,9 @@ The versioned support contract lives in [`compatibility/ocr-support.json`](../co The scheduled **OCR compatibility** workflow discovers stable upstream releases newer than the manifest monitoring floor. Drafts, prereleases, non-semantic tags, unexpected asset sets, oversized metadata or downloads, redirects outside the reviewed GitHub origins, and checksum disagreement fail closed. Every binary digest must agree with both GitHub release metadata and the upstream `sha256sum.txt`. -Candidate execution uses the verified Linux amd64 binary on an Ubuntu runner. The harness checks the reported version, the CLI flags consumed by the GitLab integration, range preview behavior, an actual JSON review through a deterministic local gateway, and the additive JSON fields consumed by posting. Evidence permits unknown new fields but requires the fields the toolkit reads. Legacy result statuses and the versioned `ocr.run-manifest/v1` outcome are normalized through one shared toolkit contract; manifest coverage sets, failure classifications, terminal state, and budget attribution must agree before a result can be published. +Candidate execution uses the verified Linux amd64 binary on an Ubuntu runner. The harness checks the reported version, the CLI flags consumed by the GitLab integration, range preview behavior, an actual JSON review through a deterministic local gateway, and the additive JSON fields consumed by posting. For OCR 1.9.0 and later it also requires JSON preview without a session-store side effect and proves that additive comment `thinking` is accepted but not published to GitLab. Upstream source review separately verifies how OCR derives that field; the toolkit probe does not claim to reproduce a provider's private reasoning channel. Evidence permits unknown new fields but requires the fields the toolkit reads. Legacy result statuses and the versioned `ocr.run-manifest/v1` outcome are normalized through one shared toolkit contract; manifest coverage sets, failure classifications, terminal state, and budget attribution must agree before a result can be published. -Built-in MCP qualification follows the protocol revisions supported by the recommended OCR release's exact MCP SDK. OCR 1.8.10 uses Go MCP SDK v1.6.1 and initiates revision `2025-11-25`; the evidence server also retains `2025-06-18`, `2025-03-26`, and `2024-11-05` for qualified older clients. For an unknown client revision the server follows MCP negotiation semantics by returning its current supported revision, leaving acceptance or termination to the client. Qualification exercises initialize, the initialized notification, ping, tool discovery, and bounded summary/list/get calls through the exact SDK rather than relying only on handcrafted JSON-RPC fixtures. +Built-in MCP qualification follows the protocol revisions supported by the recommended OCR release's exact MCP SDK. OCR 1.9.1 uses Go MCP SDK v1.6.1 and initiates revision `2025-11-25`; the evidence server also retains `2025-06-18`, `2025-03-26`, and `2024-11-05` for qualified older clients. For an unknown client revision the server follows MCP negotiation semantics by returning its current supported revision, leaving acceptance or termination to the client. Qualification exercises initialize, the initialized notification, ping, tool discovery, and bounded summary/list/get calls through the exact SDK rather than relying only on handcrafted JSON-RPC fixtures. The built-in stdio entry uses the toolkit's current absolute Python executable in isolated mode. OCR therefore does not depend on `PATH` lookup, and untrusted repository modules cannot shadow the installed toolkit when the MCP subprocess starts. @@ -25,6 +25,6 @@ An automatic-safe result is not an automatic stable release. It must still pass ## Promotion and rollback -Promotion changes `recommended_version`, advances `monitoring_floor`, adds the tested release and evidence, and updates every durable version/checksum pin. Never edit only one copy. Human-qualified candidates must record the compatibility conclusion and release-note impact. Automatic-safe candidates retain the same protected review boundary even though the patch itself is mechanical. +Promotion changes `recommended_version`, advances `monitoring_floor`, adds the tested release and evidence, and updates every durable version/checksum pin. Never edit only one copy. Human-qualified candidates must record the compatibility conclusion and release-note impact; an automatic-safe candidate may also record a reviewed conclusion when it is delivered with a human-reviewed chain instead of using the generic machine conclusion. Conclusions may name only versions present in that promotion. A reviewed promotion may cross only one adjacent semantic-version boundary at a time: the next patch, the next minor at `.0`, or the next major at `.0.0`; minor and major transitions always require an explicit human conclusion. Automatic-safe preparation remains limited to adjacent patches in the already-tested major/minor line and retains the same protected review boundary even though the patch itself is mechanical. Rollback selects a previously tested manifest entry, restores its runtime/example/documentation pins, and travels through the same release-required path. Do not delete historical evidence: it explains the prior support decision and lets future qualification distinguish a rollback from an unseen release. diff --git a/docs/configuration.md b/docs/configuration.md index 59a82f1..6170873 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,7 +27,7 @@ Open Code Review Toolkit uses environment variables for CI/runtime configuration | `OCR_MCP_SERVERS_JSON` | JSON object mapping names to bounded stdio or native Streamable HTTP definitions. | | `OCR_MCP_REPLACE` | Replace configured MCP servers when true; otherwise merge by server name. | -OCR receives every MCP as an independent named entry in its `mcp_servers` registry. The toolkit always installs `ocr_toolkit_evidence` as one mandatory entry; each configured local or remote MCP is a separate optional sibling entry and is started or contacted by OCR independently. Omitting `type` selects backward-compatible `stdio`. Stdio accepts `command`, `args`, literal `env`, `env_from`, `tools`, and `setup`. A `remote` entry accepts an absolute HTTPS `url`, non-secret `headers`, secret `headers_from`, `tools`, and `setup`. Every optional server requires a non-empty explicit `tools` allowlist so its discovered tool set cannot shadow the mandatory evidence tool. `headers_from` maps a header name to a CI variable and writes `$VARIABLE` into OCR config, so OCR 1.8.10 resolves it only when connecting. Sensitive header families such as `Authorization`, cookies, API keys, and tokens are rejected in literal `headers`. +OCR receives every MCP as an independent named entry in its `mcp_servers` registry. The toolkit always installs `ocr_toolkit_evidence` as one mandatory entry; each configured local or remote MCP is a separate optional sibling entry and is started or contacted by OCR independently. Omitting `type` selects backward-compatible `stdio`. Stdio accepts `command`, `args`, literal `env`, `env_from`, `tools`, and `setup`. A `remote` entry accepts an absolute HTTPS `url`, non-secret `headers`, secret `headers_from`, `tools`, and `setup`. Every optional server requires a non-empty explicit `tools` allowlist so its discovered tool set cannot shadow the mandatory evidence tool. `headers_from` maps a header name to a CI variable and writes `$VARIABLE` into OCR config, so the recommended OCR release resolves it only when connecting. Sensitive header families such as `Authorization`, cookies, API keys, and tokens are rejected in literal `headers`. ```json { diff --git a/docs/gitlab.md b/docs/gitlab.md index 8afc8d0..e2f010d 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -4,7 +4,7 @@ The toolkit's first provider adapter posts review results to GitLab merge reques ## Installation -Install `open-code-review-toolkit` from PyPI. The example obtains the expected toolkit wheel digest from the matching immutable GitHub Release, then uses pip hash-checking and a local install. Install Open Code Review separately and pin `v1.8.10`; verify the release checksum before making the binary executable. The package never downloads OCR. +Install `open-code-review-toolkit` from PyPI. The example obtains the expected toolkit wheel digest from the matching immutable GitHub Release, then uses pip hash-checking and a local install. Install Open Code Review separately and pin `v1.9.1`; verify the release checksum before making the binary executable. The package never downloads OCR. Copy and adapt [the synthetic CI example](../examples/gitlab/ocr-review.gitlab-ci.yml). Keep the lint stage before the AI review stage so failed project checks block review. The example downloads a pinned toolkit wheel with bounded retries/timeouts, verifies its SHA-256 before a local `--no-deps` install, generates a private evidence store plus one compact bootstrap, and passes the bootstrap once with `--background-file`. diff --git a/docs/security.md b/docs/security.md index 124d11c..fd1caca 100644 --- a/docs/security.md +++ b/docs/security.md @@ -37,7 +37,7 @@ is not an eligible project approver. GitLab approval rules, Code Owners, protected branches, and reauthentication remain server-side controls; the toolkit does not bypass them. -Pin Open Code Review `v1.8.10` and verify its checksum. Pin Python dependencies through `uv.lock` and GitHub Actions by immutable commit SHA. MCP stdio commands and remote endpoints are privileged configuration; allow only reviewed servers and tools. +Pin Open Code Review `v1.9.1` and verify its checksum. Pin Python dependencies through `uv.lock` and GitHub Actions by immutable commit SHA. MCP stdio commands and remote endpoints are privileged configuration; allow only reviewed servers and tools. The [OCR compatibility policy](compatibility.md) requires double-source asset digest verification, bounded downloads, an executed Linux contract probe, and protected PR/release gates; qualification automation never writes directly to `main` or promotes an ambiguous release. Remote MCP is HTTPS-only, forbids URL userinfo and fragments, and never logs endpoint URLs or header values. Put credentials in protected/masked CI variables and reference them through `headers_from`; literal credential-like headers fail closed. OCR expands the resulting `$VARIABLE` at connection time. Full browser OAuth, PKCE, refresh-token persistence, tenant binding, and revocation remain conditional on a named supported-provider requirement; use a reviewed stdio OAuth proxy when those flows are required today. diff --git a/examples/gitlab/ocr-review.gitlab-ci.yml b/examples/gitlab/ocr-review.gitlab-ci.yml index f94025c..9bdf2b7 100644 --- a/examples/gitlab/ocr-review.gitlab-ci.yml +++ b/examples/gitlab/ocr-review.gitlab-ci.yml @@ -6,10 +6,10 @@ default: image: python:3.12-slim variables: - OCR_VERSION: "v1.8.10" + OCR_VERSION: "v1.9.1" OCR_TOOLKIT_VERSION: "0.1.0" OCR_TOOLKIT_CHECKSUMS_URL: "https://github.com/xeonvs/open-code-review-toolkit/releases/download/v0.1.0/SHA256SUMS" - OCR_SHA256: "7161500791b8d27906ee8a29bf4429953b27048e90e33dd9a4ff6118932c9001" + OCR_SHA256: "9cb546e4f29389e3b7d768becc34a18cf2aaa6635610459fa65a7ea32a6c8bec" OCR_POST_MODE: "draft" OCR_STRICT_POSTING: "true" # Default-on exact-SHA approval; set "false" for a comment-only bot. diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py index 5985ee6..6b2551e 100644 --- a/scripts/ocr_compat.py +++ b/scripts/ocr_compat.py @@ -11,6 +11,7 @@ import json import os import re +import shutil import stat import subprocess import sys @@ -127,6 +128,20 @@ def _version(value: str) -> tuple[int, int, int]: return tuple(int(part) for part in match.groups()) # type: ignore[return-value] +def _release_transition( + previous: tuple[int, int, int], candidate: tuple[int, int, int] +) -> str | None: + """Classify one adjacent SemVer transition accepted for reviewed promotion.""" + + if candidate[:2] == previous[:2] and candidate[2] == previous[2] + 1: + return "patch" + if candidate[0] == previous[0] and candidate[1] == previous[1] + 1 and candidate[2] == 0: + return "minor" + if candidate[0] == previous[0] + 1 and candidate[1:] == (0, 0): + return "major" + return None + + def canonical_json(value: Any) -> bytes: """Return deterministic UTF-8 JSON bytes.""" @@ -533,21 +548,47 @@ def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> return completed.stdout -def _synthetic_repo(directory: Path) -> tuple[Path, str, str]: +def _isolated_probe_environment(home: Path) -> dict[str, str]: + """Return a private OCR/Git environment independent of operator configuration.""" + + home.mkdir(mode=0o700) + temp = home / "tmp" + temp.mkdir(mode=0o700) + git = shutil.which("git") + if git is None or not Path(git).is_absolute(): + _fail("qualification requires an absolute Git executable") + return { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_SYSTEM": os.devnull, + "HOME": str(home), + "LANG": "C", + "LC_ALL": "C", + "NO_PROXY": "127.0.0.1,localhost", + "PATH": os.pathsep.join((str(Path(git).parent), os.defpath)), + "TMPDIR": str(temp), + "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + } + + +def _synthetic_repo(directory: Path, env: dict[str, str]) -> tuple[Path, str, str]: + """Create an immutable two-commit fixture under isolated Git configuration.""" + repo = directory / "synthetic-review" repo.mkdir() - _run(["git", "init", "--initial-branch=main"], cwd=repo) - _run(["git", "config", "user.name", "Synthetic Reviewer"], cwd=repo) - _run(["git", "config", "user.email", "reviewer@example.com"], cwd=repo) + _run(["git", "init", "--initial-branch=main"], cwd=repo, env=env) + _run(["git", "config", "user.name", "Synthetic Reviewer"], cwd=repo, env=env) + _run(["git", "config", "user.email", "reviewer@example.com"], cwd=repo, env=env) target = repo / "example.py" target.write_text("def value():\n return 1\n", encoding="utf-8") - _run(["git", "add", "example.py"], cwd=repo) - _run(["git", "commit", "-m", "baseline"], cwd=repo) - base = _run(["git", "rev-parse", "HEAD"], cwd=repo).strip() + _run(["git", "add", "example.py"], cwd=repo, env=env) + _run(["git", "commit", "-m", "baseline"], cwd=repo, env=env) + base = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=env).strip() target.write_text("def value():\n return 2\n", encoding="utf-8") - _run(["git", "add", "example.py"], cwd=repo) - _run(["git", "commit", "-m", "change value"], cwd=repo) - head = _run(["git", "rev-parse", "HEAD"], cwd=repo).strip() + _run(["git", "add", "example.py"], cwd=repo, env=env) + _run(["git", "commit", "-m", "change value"], cwd=repo, env=env) + head = _run(["git", "rev-parse", "HEAD"], cwd=repo, env=env).strip() return repo, base, head @@ -580,6 +621,7 @@ def do_POST(self) -> None: { "content": "Synthetic compatibility finding.", "existing_code": " return 2", + "thinking": "Synthetic private compatibility reasoning.", "category": "maintainability", "severity": "low", } @@ -677,7 +719,8 @@ def run_contracts(binary: Path, version: str, directory: Path) -> dict[str, Any] """Run deterministic CLI and JSON-consumer probes against one OCR binary.""" binary.chmod(binary.stat().st_mode | stat.S_IXUSR) - repo, base, head = _synthetic_repo(directory) + git_env = _isolated_probe_environment(directory / "git-home") + repo, base, head = _synthetic_repo(directory, git_env) version_output = _run([str(binary), "--version"], cwd=repo) if re.search(rf"(? dict[str, Any] missing = sorted(flag for flag in REQUIRED_REVIEW_FLAGS if flag not in help_output) if missing: _fail(f"candidate review help is missing required flags: {', '.join(missing)}") - preview = _run([str(binary), "review", "--from", base, "--to", head, "--preview"], cwd=repo) - if "example.py" not in preview: + preview_home = directory / "preview-home" + preview_env = _isolated_probe_environment(preview_home) + preview_command = [str(binary), "review", "--from", base, "--to", head, "--preview"] + json_preview = _version(version) >= (1, 9, 0) + if json_preview: + preview_command.extend(["--format", "json"]) + preview = _run(preview_command, cwd=repo, env=preview_env) + if json_preview: + try: + preview_payload = json.loads(preview) + except json.JSONDecodeError as exc: + raise CompatibilityError("candidate JSON preview did not emit JSON") from exc + preview_files = preview_payload.get("files") if isinstance(preview_payload, dict) else None + if not isinstance(preview_files, list) or not any( + isinstance(item, dict) and item.get("path") == "example.py" for item in preview_files + ): + _fail("candidate JSON preview did not select the synthetic changed file") + elif "example.py" not in preview: _fail("candidate preview did not select the synthetic changed file") + if os.path.lexists(preview_home / ".opencodereview" / "sessions"): + _fail("candidate preview created a review session store") - env = dict(os.environ) + review_home = directory / "review-home" + env = _isolated_probe_environment(review_home) with _stub_gateway() as gateway_url: env.update( { @@ -756,12 +818,28 @@ def run_contracts(binary: Path, version: str, directory: Path) -> dict[str, Any] _fail("toolkit token summary rejected the candidate result contract") if "1 total" not in format_tool_calls_summary(sample.get("tool_calls")): _fail("toolkit tool-call summary rejected the candidate result contract") + thinking_probe: dict[str, Any] | None = None + if _version(version) >= (1, 9, 0): + if comment.get("thinking") != "Synthetic private compatibility reasoning.": + _fail("candidate did not preserve additive comment thinking") + if "Synthetic private compatibility reasoning." in rendered: + _fail("toolkit posting consumer exposed private comment thinking") + thinking_probe = { + "additive_field_preserved": True, + "posting_exposes_thinking": False, + "result": "passed", + } - return { + contracts: dict[str, Any] = { "optional_capabilities": optional_capabilities, "version_probe": "passed", "required_review_flags": sorted(REQUIRED_REVIEW_FLAGS), - "preview_probe": {"path": "example.py", "result": "passed"}, + "preview_probe": { + "format": "json" if json_preview else "text", + "path": "example.py", + "result": "passed", + "session_store_created": False, + }, "result_contract_probe": { "additive_fields_allowed": True, "comment_fields": sorted(comment), @@ -770,6 +848,9 @@ def run_contracts(binary: Path, version: str, directory: Path) -> dict[str, Any] "result": "passed", }, } + if thinking_probe is not None: + contracts["comment_thinking_probe"] = thinking_probe + return contracts def classify_candidate( @@ -977,8 +1058,9 @@ def prepare_update( _fail(f"candidate evidence chain contains duplicate version {version}") candidate = _version(version) comparison = _version(expected_comparison) - if candidate[:2] != comparison[:2] or candidate[2] != comparison[2] + 1: - _fail("candidate evidence chain is not a contiguous same-minor patch sequence") + transition = _release_transition(comparison, candidate) + if transition is None: + _fail("candidate evidence chain is not a contiguous release sequence") if item.get("result") != "compatible": _fail(f"candidate evidence does not qualify {version} as compatible") schema_version = item.get("schema_version") @@ -990,25 +1072,26 @@ def prepare_update( elif schema_version != 1 or len(evidences) != 1: _fail("multi-release promotion requires chain-aware evidence schema 2") classification = item.get("classification") + conclusion = conclusions.get(version) + if conclusion is not None and ( + not isinstance(conclusion, str) + or not conclusion.strip() + or len(conclusion) > 2_000 + or any(ord(character) < 32 and character not in "\n\t" for character in conclusion) + ): + _fail(f"candidate {version} conclusion must be bounded plain text") if classification == "human-review-required": - conclusion = conclusions.get(version) - if ( - not isinstance(conclusion, str) - or not conclusion.strip() - or len(conclusion) > 2_000 - or any(ord(character) < 32 and character not in "\n\t" for character in conclusion) - ): + if conclusion is None: _fail(f"human-reviewed candidate {version} requires a bounded conclusion") elif classification != "automatic-safe": _fail(f"candidate evidence {version} has an invalid classification") + if transition != "patch" and classification != "human-review-required": + _fail("minor and major promotions require explicit human review") versions.append(version) expected_comparison = version - if set(conclusions) != { - version - for version, item in zip(versions, evidences, strict=True) - if item.get("classification") == "human-review-required" - }: - _fail("human conclusions must match exactly the human-reviewed evidence versions") + unknown_conclusions = set(conclusions).difference(versions) + if unknown_conclusions: + _fail("human conclusions may reference only evidence versions in this promotion") version = versions[-1] releases = manifest.get("releases") diff --git a/src/ocr_toolkit/preflight.py b/src/ocr_toolkit/preflight.py index 6e03d1d..e16ee19 100644 --- a/src/ocr_toolkit/preflight.py +++ b/src/ocr_toolkit/preflight.py @@ -24,7 +24,7 @@ "Accept": "application/json", "User-Agent": "open-code-review-ci-preflight/1.0", } -EXPECTED_OCR_VERSION = "1.8.10" +EXPECTED_OCR_VERSION = "1.9.1" class PreflightError(Exception): diff --git a/tests/test_evidence_mcp.py b/tests/test_evidence_mcp.py index 35bf789..ff9d4b0 100644 --- a/tests/test_evidence_mcp.py +++ b/tests/test_evidence_mcp.py @@ -169,8 +169,8 @@ def test_json_rpc_initialize_lists_read_only_tool_and_returns_safe_errors() -> N assert failed and failed["result"]["isError"] is True # type: ignore[index] -def test_initialize_supports_exact_ocr_1_8_sdk_protocol_revisions() -> None: - """Negotiate every revision supported by OCR 1.8.10's Go MCP SDK.""" +def test_initialize_supports_exact_recommended_ocr_sdk_protocol_revisions() -> None: + """Negotiate every revision supported by OCR 1.9.1's Go MCP SDK.""" assert PROTOCOL_VERSION == "2025-11-25" assert { @@ -189,7 +189,7 @@ def test_initialize_supports_exact_ocr_1_8_sdk_protocol_revisions() -> None: "params": { "protocolVersion": version, "capabilities": {}, - "clientInfo": {"name": "ocr", "version": "1.8.10"}, + "clientInfo": {"name": "ocr", "version": "1.9.1"}, }, }, ) diff --git a/tests/test_integration_contracts.py b/tests/test_integration_contracts.py index 75cc366..bf6fe7e 100644 --- a/tests/test_integration_contracts.py +++ b/tests/test_integration_contracts.py @@ -52,6 +52,18 @@ def test_gitlab_docs_match_the_current_review_surface() -> None: configuration = (PROJECT_ROOT / "docs" / "configuration.md").read_text(encoding="utf-8") security = (PROJECT_ROOT / "docs" / "security.md").read_text(encoding="utf-8") workflow = (HELPER_DIR / "ocr-review.gitlab-ci.yml").read_text(encoding="utf-8") + manifest = json.loads( + (PROJECT_ROOT / "compatibility" / "ocr-support.json").read_text(encoding="utf-8") + ) + recommended = manifest["recommended_version"] + recommended_entry = next( + item for item in manifest["releases"] if item["version"] == recommended + ) + linux_digest = next( + asset["sha256"] + for asset in recommended_entry["assets"] + if asset["name"] == "opencodereview-linux-amd64" + ) for command in ("ocr-ci review", "ocr-ci post"): assert command in docs @@ -68,12 +80,10 @@ def test_gitlab_docs_match_the_current_review_surface() -> None: assert "not a source-code parser" in configuration assert "Target/base guidance may describe policy" in security assert "changed source/head guidance and accepted decisions cannot authorize" in security - assert 'OCR_VERSION: "v1.8.10"' in workflow - assert "v1.8.10" in docs - assert "v1.8.10" in security - assert ( - 'OCR_SHA256: "7161500791b8d27906ee8a29bf4429953b27048e90e33dd9a4ff6118932c9001"' in workflow - ) + assert f'OCR_VERSION: "v{recommended}"' in workflow + assert f"v{recommended}" in docs + assert f"v{recommended}" in security + assert f'OCR_SHA256: "{linux_digest}"' in workflow assert "`Russian` is one example" in docs assert "ocr-ci preflight" in workflow assert "ocr-ci configure" in workflow @@ -83,7 +93,7 @@ def test_gitlab_docs_match_the_current_review_surface() -> None: assert "review-background.md" not in workflow assert '--from "${CI_MERGE_REQUEST_DIFF_BASE_SHA}"' in workflow assert '--to "${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA}"' in workflow - assert "Pin Open Code Review `v1.8.10` and verify its checksum" in security + assert f"Pin Open Code Review `v{recommended}` and verify its checksum" in security assert "when: manual" in workflow assert "env -u OCR_LLM_TOKEN" in workflow diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py index e3ea2b1..1956d42 100644 --- a/tests/test_ocr_compat.py +++ b/tests/test_ocr_compat.py @@ -43,8 +43,8 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No module.validate_manifest(manifest, PROJECT_ROOT) - assert manifest["recommended_version"] == "1.8.10" - assert manifest["monitoring_floor"] == "1.8.10" + assert manifest["recommended_version"] == "1.9.1" + assert manifest["monitoring_floor"] == "1.9.1" assert [(item["version"], item["status"]) for item in manifest["releases"]] == [ ("1.7.17", "tested"), ("1.8.0", "tested"), @@ -58,6 +58,8 @@ def test_committed_manifest_is_valid_and_has_recommended_tested_baseline() -> No ("1.8.8", "tested"), ("1.8.9", "tested"), ("1.8.10", "tested"), + ("1.9.0", "tested"), + ("1.9.1", "tested"), ] @@ -120,9 +122,9 @@ def test_discovery_filters_known_prerelease_and_old_versions() -> None: def test_discovery_pages_until_the_monitoring_floor() -> None: module = load_script() manifest = module.load_json(MANIFEST) - first_page = [release("1.8.11")] + first_page = [release("1.9.2")] first_page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) - second_page = [release("1.8.10")] + second_page = [release("1.9.1")] requested: list[str] = [] def fake_request(url: str) -> list[dict[str, Any]]: @@ -132,14 +134,14 @@ def fake_request(url: str) -> list[dict[str, Any]]: with patched_attr(module, "_request_json", fake_request): unseen = module.discover_unseen(manifest) - assert [item["tag_name"] for item in unseen] == ["v1.8.11"] + assert [item["tag_name"] for item in unseen] == ["v1.9.2"] assert len(requested) == 2 def test_discovery_fails_when_bounded_pages_do_not_reach_floor() -> None: module = load_script() manifest = module.load_json(MANIFEST) - page = [release("1.8.11")] + page = [release("1.9.2")] page.extend({"draft": True} for _ in range(module.MAX_RELEASES_PER_PAGE - 1)) with patched_attr(module, "_request_json", lambda _url: page): @@ -184,14 +186,14 @@ def test_qualification_matrix_accepts_the_next_manual_patch() -> None: module = load_script() manifest = module.load_json(MANIFEST) - matrix = module.qualification_matrix(manifest, [release("1.8.11")]) + matrix = module.qualification_matrix(manifest, [release("1.9.2")]) assert matrix == { "include": [ { - "comparison_version": "1.8.10", - "tag": "v1.8.11", - "tested_baseline_version": "1.8.10", + "comparison_version": "1.9.1", + "tag": "v1.9.2", + "tested_baseline_version": "1.9.1", } ] } @@ -243,6 +245,28 @@ def test_automatic_safe_policy_is_conservative() -> None: assert any("newer patch" in reason for reason in skipped_reasons) +@pytest.mark.parametrize( + ("previous", "candidate", "expected"), + [ + ((1, 8, 10), (1, 8, 11), "patch"), + ((1, 8, 10), (1, 9, 0), "minor"), + ((1, 9, 9), (2, 0, 0), "major"), + ((1, 8, 10), (1, 9, 1), None), + ((1, 8, 10), (1, 10, 0), None), + ((1, 8, 10), (2, 0, 1), None), + ((1, 8, 10), (1, 8, 10), None), + ], +) +def test_release_transition_accepts_only_adjacent_semver_steps( + previous: tuple[int, int, int], + candidate: tuple[int, int, int], + expected: str | None, +) -> None: + module = load_script() + + assert module._release_transition(previous, candidate) == expected + + def test_checksum_file_rejects_traversal_and_duplicates(tmp_path: Path) -> None: module = load_script() checksum = tmp_path / "sha256sum.txt" @@ -252,6 +276,38 @@ def test_checksum_file_rejects_traversal_and_duplicates(tmp_path: Path) -> None: module.parse_checksum_file(checksum) +def test_probe_environment_ignores_operator_ocr_and_git_configuration( + tmp_path: Path, +) -> None: + module = load_script() + home = tmp_path / "probe-home" + + with patched_env( + GIT_DIR="/tmp/untrusted-git-dir", + GIT_CONFIG_COUNT="1", + OCR_CONFIG_PATH="/tmp/operator-config.json", + OCR_LLM_PROVIDER="operator-provider", + ): + env = module._isolated_probe_environment(home) + + assert env["HOME"] == str(home) + assert env["XDG_CONFIG_HOME"] == str(home / ".config") + assert env["GIT_CONFIG_GLOBAL"] == module.os.devnull + assert env["GIT_CONFIG_SYSTEM"] == module.os.devnull + assert env["PATH"].split(module.os.pathsep)[0] == str( + Path(module.shutil.which("git") or "").parent + ) + assert env["TMPDIR"] == str(home / "tmp") + assert not any(key.startswith("OCR_") for key in env) + assert not any( + key.startswith("GIT_") + for key in env + if key not in {"GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM"} + ) + assert home.stat().st_mode & 0o777 == 0o700 + assert (home / "tmp").stat().st_mode & 0o777 == 0o700 + + def test_issue_body_uses_stable_marker_and_safe_release_changes() -> None: module = load_script() evidence = { @@ -796,11 +852,11 @@ def test_prepare_update_rejects_human_review_candidate(tmp_path: Path) -> None: module = load_script() evidence = { "schema_version": 2, - "version": "1.8.11", + "version": "1.9.2", "result": "compatible", "classification": "human-review-required", - "comparison_version": "1.8.10", - "tested_baseline_version": "1.8.10", + "comparison_version": "1.9.1", + "tested_baseline_version": "1.9.1", } with pytest.raises(module.CompatibilityError, match="bounded conclusion"): @@ -810,3 +866,89 @@ def test_prepare_update_rejects_human_review_candidate(tmp_path: Path) -> None: fragment_number=42, root=PROJECT_ROOT, ) + + +def test_prepare_update_requires_human_review_for_minor_transition() -> None: + module = load_script() + evidence = { + "schema_version": 2, + "version": "1.10.0", + "result": "compatible", + "classification": "automatic-safe", + "comparison_version": "1.9.1", + "tested_baseline_version": "1.9.1", + } + + with pytest.raises(module.CompatibilityError, match="explicit human review"): + module.prepare_update( + manifest_path=MANIFEST, + evidence=evidence, + fragment_number=73, + root=PROJECT_ROOT, + ) + + +def test_prepare_update_rejects_nonadjacent_minor_transition() -> None: + module = load_script() + evidence = { + "schema_version": 2, + "version": "1.11.0", + "result": "compatible", + "classification": "human-review-required", + "comparison_version": "1.9.1", + "tested_baseline_version": "1.9.1", + } + + with pytest.raises(module.CompatibilityError, match="contiguous release sequence"): + module.prepare_update( + manifest_path=MANIFEST, + evidence=evidence, + fragment_number=73, + human_conclusions={"1.11.0": "Synthetic reviewed conclusion."}, + root=PROJECT_ROOT, + ) + + +def test_prepare_update_rejects_conclusion_outside_evidence_chain() -> None: + module = load_script() + evidence = { + "schema_version": 2, + "version": "1.9.2", + "result": "compatible", + "classification": "automatic-safe", + "comparison_version": "1.9.1", + "tested_baseline_version": "1.9.1", + } + + with pytest.raises(module.CompatibilityError, match="only evidence versions"): + module.prepare_update( + manifest_path=MANIFEST, + evidence=evidence, + fragment_number=72, + human_conclusions={"1.9.3": "Synthetic unrelated conclusion."}, + root=PROJECT_ROOT, + ) + + +@pytest.mark.parametrize("conclusion", ["", "x" * 2_001, "unsafe\x00text"]) +def test_prepare_update_rejects_invalid_optional_reviewed_conclusion( + conclusion: str, +) -> None: + module = load_script() + evidence = { + "schema_version": 2, + "version": "1.9.2", + "result": "compatible", + "classification": "automatic-safe", + "comparison_version": "1.9.1", + "tested_baseline_version": "1.9.1", + } + + with pytest.raises(module.CompatibilityError, match="bounded plain text"): + module.prepare_update( + manifest_path=MANIFEST, + evidence=evidence, + fragment_number=72, + human_conclusions={"1.9.2": conclusion}, + root=PROJECT_ROOT, + ) diff --git a/tests/test_runtime_helpers.py b/tests/test_runtime_helpers.py index eb8d609..a6e5203 100644 --- a/tests/test_runtime_helpers.py +++ b/tests/test_runtime_helpers.py @@ -835,7 +835,7 @@ def test_invalid_json_error_does_not_echo_secret_payload(self) -> None: class PreflightTests(unittest.TestCase): def test_validate_ocr_binary_accepts_supported_version(self) -> None: completed = subprocess.CompletedProcess( - args=["ocr", "--version"], returncode=0, stdout="ocr 1.8.10\n", stderr="" + args=["ocr", "--version"], returncode=0, stdout="ocr 1.9.1\n", stderr="" ) with ( patched_attr(preflight.shutil, "which", lambda _name: "/usr/bin/ocr"), From fe88f8d78744847bc58b35129de5c9130cd46853 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:22:54 +0200 Subject: [PATCH 6/7] Validate toolkit 0.4.7 feature tip --- PLANS.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/PLANS.md b/PLANS.md index 761ca3d..e330f96 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,7 +4,7 @@ Use this file for active, blocked, or recently completed execution work. Update ## Active Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7 -Status: active; implementation, release-lifecycle, and OCR 1.9.1 qualification checkpoints complete +Status: active; feature-tip implementation, validation/E2E, release-lifecycle, and OCR 1.9.1 qualification checkpoints complete Owner: Codex Last Updated: 2026-08-11 Release Classification: release-required @@ -73,7 +73,7 @@ independently read back. classifications and qualification issues, update compatibility records and the local checksum-pinned OCR 1.9.1 binary, and adapt the toolkit only where evidence requires it. -5. [ ] Reconcile this plan, roadmap table/diagram, backlog, and current-state +5. [x] Reconcile this plan, roadmap table/diagram, backlog, and current-state documentation against the implemented code. Run focused tests, the synthetic GitLab E2E, Python 3.12 quality, Towncrier draft, workflow/document/privacy checks, and `git diff --check`. @@ -87,8 +87,8 @@ independently read back. 8. [ ] Independently verify the exact TestPyPI development artifacts, hashes, provenance, and supported-Python installs before preparing `release/v0.4.7`. 9. [ ] Prepare and validate the final release PR, consuming fragments 69, 70, - and 71 and reconciling repository-side planning truth without claiming - publication that has not happened. + 71, 72, and 73 and reconciling repository-side planning truth without + claiming publication that has not happened. 10. [ ] Merge the release PR only after exact-head protected checks. Verify stable TestPyPI/PyPI artifacts, provenance/attestations, annotated tag, immutable GitHub Release and release receipt, hashes, and Python 3.12-3.14 installs. @@ -254,6 +254,48 @@ independently read back. releases. The gate uses `.quality-logs/py312` and does not mutate the host `.venv` or tracked checkout. +### Feature-tip Validation And E2E Checkpoint + +- The signed `4f3dd29` tree builds on Python 3.12.13 as + `0.4.7.dev6+g4f3dd2994`. Twine accepts both distributions; the wheel SHA-256 + is `295c0e9fa52492aa9e99c7dd11ae0b3a2b2c6339f3e4991ea3009e29812ed358` + and the sdist SHA-256 is + `c679d9490f8cb1fb58f37bac4eba24dcad52fb745814121523c2841a15096c55`. + Metadata derives the version from SCM, requires Python 3.12 through 3.14, + declares no runtime dependencies, and both archives contain only their + intended package/source surfaces. +- Separate clean Python 3.12 wheel and hash-locked sdist installs pass + `pip check`, import the same centralized version, and run the installed + `ocr-ci --help` entry point under a restricted `PATH` from a repository that + contains a hostile local `ocr_toolkit` shadow package. The installed artifact, + rather than checkout code or the untrusted current directory, owns execution. +- One ignored, one-off synthetic repository E2E uses the installed wheel and the + official local OCR 1.9.1 binary without adding a permanent harness. A local + deterministic gateway forces OCR to query `ocr_toolkit_evidence` before it + emits one synthetic finding. OCR finishes with `status=complete` and one + built-in evidence call; `_ocr_toolkit.mcp_usage` records the same count. The + exact base/head snapshots match the reviewed commits, `.review-context` is + absent from Git status and the reviewed diff, its directory is `0700`, all + store/bootstrap/result/stderr artifacts are `0600`, and no GitLab posting + command is invoked. +- The complete posting/suggestion/approval/release/compatibility changed-surface + suite passes 347 tests plus 73 subtests. The full gate executed directly from + Python 3.12.13 passes 622 tests plus 81 subtests at 79.61% coverage; formatting, + Ruff, strict mypy, Bandit, changed-shell ShellCheck, workflow YAML parsing, + OCR manifest validation, Towncrier 0.4.7 draft, changed-public-content privacy, + and `git diff --check` pass. +- Read-only validation against current public service payloads confirms that + PyPI Integrity v1 uses the publisher and in-toto subject shape enforced by the + release verifier, GitHub exposes strict effective `main` checks with exact App + integration IDs, and immutable Release state is available through the pinned + API version. No registry, Release, issue, or GitLab state was changed. +- Final reconciliation found no roadmap table/diagram, strategy, or backlog + status transition: #70/#71 are release-scoped behavior rather than an outcome + milestone, while OCR 1.9.0/1.9.1 inputs leave BL-008/009/010/015/016/017 at + their documented triggers. Self-review corrected the release queue to consume + all five fragments 69-73; every tracked issue remains open until stable 0.4.7 + delivery is proven by the immutable receipt and independent readback. + ## Completed Plan: Reconcile 0.4.6 lifecycle, architecture, and backlog truth Status: completed; validated documentation/process PR handoff From 2f63d250cb47ab3c7bcc174514949f7bb2d6e044 Mon Sep 17 00:00:00 2001 From: xeonvs <11463419+xeonvs@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:27:51 +0200 Subject: [PATCH 7/7] Address final OCR review findings --- .github/workflows/release.yml | 13 +- AGENTS.md | 4 + PLANS.md | 106 +++++++++++--- README.md | 3 +- changelog.d/71.feature.md | 2 +- docs/codex/AGENT_EXECUTION_PITFALLS.md | 46 ++++++ docs/configuration.md | 10 +- docs/engineering/project_principles.md | 4 + docs/gitlab.md | 5 +- docs/operations.md | 18 ++- docs/release.md | 26 +++- docs/security.md | 14 +- scripts/bounded_github_api.sh | 39 ++++- scripts/ocr_compat.py | 2 + scripts/release_authorization.py | 5 + scripts/release_receipt.py | 4 +- scripts/testpypi_preview.py | 4 +- src/ocr_toolkit/posting/approval.py | 16 +- src/ocr_toolkit/posting/gitlab.py | 11 -- src/ocr_toolkit/posting/gitlab_approval.py | 87 +++-------- src/ocr_toolkit/posting/markers.py | 48 ------ src/ocr_toolkit/posting/snapshot.py | 24 --- src/ocr_toolkit/posting/suggestions.py | 9 +- src/ocr_toolkit/posting/workflow.py | 29 +--- tests/test_ocr_compat.py | 22 +++ tests/test_operations_docs.py | 11 +- tests/test_posting_approval.py | 162 +++------------------ tests/test_posting_suggestions.py | 15 +- tests/test_release_authorization.py | 71 ++++++++- tests/test_release_receipt.py | 28 ++++ tests/test_testpypi_preview.py | 5 + 31 files changed, 450 insertions(+), 393 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9a882a..016ef51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,10 @@ on: description: Exact reviewed release pull-request head SHA required: true type: string + reviewed-base: + description: Exact protected base SHA that owns release authorization policy + required: true + type: string permissions: contents: read @@ -79,17 +83,21 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.event.pull_request.merge_commit_sha || inputs['merge-commit'] }} + # Authorization code must come from protected policy that predates the + # release PR. Candidate head and merge commits are inspected only as data. + ref: ${{ github.event.pull_request.base.sha || inputs['reviewed-base'] }} persist-credentials: false - id: authorize env: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} INPUT_PR_NUMBER: ${{ inputs['pull-request-number'] }} INPUT_VERSION: ${{ inputs.version }} INPUT_COMMIT: ${{ inputs['merge-commit'] }} INPUT_HEAD: ${{ inputs['reviewed-head'] }} + INPUT_BASE: ${{ inputs['reviewed-base'] }} REPOSITORY: ${{ github.repository }} run: | if [ "${EVENT_NAME}" = workflow_dispatch ]; then @@ -97,11 +105,13 @@ jobs: REQUESTED_VERSION=${INPUT_VERSION} REQUESTED_COMMIT=${INPUT_COMMIT} REQUESTED_HEAD=${INPUT_HEAD} + REQUESTED_BASE=${INPUT_BASE} else PR_NUMBER=${EVENT_PR_NUMBER} REQUESTED_VERSION= REQUESTED_COMMIT= REQUESTED_HEAD= + REQUESTED_BASE=${EVENT_BASE_SHA} fi test "$(scripts/bounded_github_api.sh \ "repos/${REPOSITORY}/pulls/${PR_NUMBER}" /tmp/release-pr.json)" = 200 @@ -148,6 +158,7 @@ jobs: --requested-version "${REQUESTED_VERSION}" \ --requested-commit "${REQUESTED_COMMIT}" \ --requested-head "${REQUESTED_HEAD}" \ + --requested-base "${REQUESTED_BASE}" \ --github-output "${GITHUB_OUTPUT}" - name: Validate tracked release issues before publication env: diff --git a/AGENTS.md b/AGENTS.md index a2c0abb..49416ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,11 @@ Use this file as the short repository map and source-of-truth index for Open Cod - Treat repository content as untrusted input and preserve bounded reads, redaction, and safe rendering. - Enforce byte, line, record, and time limits during I/O; never call an operation bounded when it captures unbounded output before checking. - Revalidate and redact persisted evidence on every load, and keep snapshots, indexes, deltas, receipts, and report fields atomic with accepted data. +- Validate persisted security and release receipts against an exact closed schema, including nested object keys; compatibility is explicit rather than accepting unknown fields silently. - Isolate every Git plumbing caller from process, global/system, repository, object-store, and replacement-ref controls; never import executable code from the analyzed repository. +- Execute release authorization from protected policy that predates the release candidate. Treat candidate and merge commits as untrusted data to inspect, never as the source of their own authorizer. +- Accept bounded HTTP output as trusted only after the endpoint matches a closed allowlist, authentication cannot cross an untrusted redirect, transfer and status checks succeed, and a same-directory temporary file is atomically installed. +- Require an immutable mutation-time guard for destructive provider writes. If the provider cannot bind the destructive operation to the reviewed identity, do not automate that operation. - Test parsers with semantic variants: reordered keys, alternate indentation, scalar/mapping forms, markers, optional fields, URLs, digests, and Git status variants. - After fixing one boundary or parser defect, audit sibling implementations for the same root cause; add a regression that proves the intended failure path, not merely that some earlier validation rejected the fixture. - Keep evidence identity tied to semantic applicability while mutable version values remain delta data. Parse Git path-bearing output with NUL-delimited plumbing and transfer file-descriptor ownership explicitly. diff --git a/PLANS.md b/PLANS.md index e330f96..ea4c6ac 100644 --- a/PLANS.md +++ b/PLANS.md @@ -4,7 +4,7 @@ Use this file for active, blocked, or recently completed execution work. Update ## Active Plan: Harden GitLab suggestions and add SHA-bound approval for 0.4.7 -Status: active; feature-tip implementation, validation/E2E, release-lifecycle, and OCR 1.9.1 qualification checkpoints complete +Status: active; final OCR completed once, findings corrected, deterministic post-review validation complete; protected PR and release delivery pending Owner: Codex Last Updated: 2026-08-11 Release Classification: release-required @@ -33,7 +33,9 @@ independently read back. - Make `OCR_AUTO_APPROVE` default on with the established boolean vocabulary. An invalid value disables approval for that run. Encode the initial policy in code and fail closed when authoritative completeness or typed finding - metadata cannot be proven. + metadata cannot be proven. Keep the transaction add-only: GitLab cannot bind + unapproval to the reviewed SHA at mutation time, so ineligible or disabled + later reviews preserve every existing approval. - After the release-lifecycle checkpoint, qualify the contiguous Open Code Review 1.9.0 and 1.9.1 chain from authoritative release/source evidence. Preserve a separate checksum/contract record and human impact conclusion for @@ -62,8 +64,10 @@ independently read back. proof, bounded omission reasons, documentation, complete regressions, review, and the #70 checkpoint commit. 2. [x] Implement typed auto-approval configuration and policy, exact-SHA GitLab - synchronization/write/readback, managed own-user approval receipts, - documentation, complete regressions, review, and the #71 checkpoint commit. + synchronization/write/readback, add-only provider semantics, documentation, + complete regressions, review, and the #71 checkpoint commit. The original + managed-unapproval design was removed after final OCR found that GitLab + cannot provide the required mutation-time immutable guard. 3. [x] Replace the redundant post-release closure-PR contract with exact-tree release authorization and deterministic `ocr-toolkit.release-receipt/v1` evidence; update durable rules, recovery behavior, tests, and the lifecycle @@ -77,10 +81,10 @@ independently read back. documentation against the implemented code. Run focused tests, the synthetic GitLab E2E, Python 3.12 quality, Towncrier draft, workflow/document/privacy checks, and `git diff --check`. -6. [ ] Commit the complete feature tip and run one local toolkit-owned OCR review +6. [x] Commit the complete feature tip and run one local toolkit-owned OCR review with private result/stderr artifacts, no GitLab posting, and verified nonzero - built-in MCP use. Correct findings and complete final self-review without a - second OCR or Codex Security run. + built-in MCP use. Correct its findings, complete deterministic validation and + final self-review, and do not run a second OCR or Codex Security review. 7. [ ] Run deterministic post-review validation and pinned local Gitleaks over the unpublished history, push the exact reviewed branch, open one feature PR, resolve every conversation, pass protected checks, and squash-merge. @@ -145,24 +149,24 @@ independently read back. bounded MR and full paginated diff-version state, selects the highest valid version ID, waits at most ten two-second intervals for merge/approval synchronization and a non-null patch ID, verifies the open current head, and - submits only the reviewed 40-hex SHA. Approve, unapprove, and summary-update - writes are attempted once and followed by bounded readback. -- Versioned managed-approval receipts are accepted only from the fixed prefix of - an owned plain toolkit summary. Conflicting, forged fallback, malformed, or - wrong-user receipts cannot authorize unapproval. A later complete - authoritative ineligible review can remove only the authenticated user's - proven managed approval; partial, skipped, legacy, disabled, and ambiguous - states preserve it. No runtime path calls GitLab `reset_approvals`. + submits only the reviewed 40-hex SHA. Approve and summary-update writes are + attempted once and followed by bounded readback. +- Approval is add-only. An already approved toolkit user is reported as skipped + without a provider write; ineligible, partial, skipped, legacy, disabled, and + ambiguous runs also make no approval write. The adapter exposes no unapprove + operation or managed-approval receipt because GitLab cannot bind unapproval to + the immutable reviewed SHA at mutation time. Project-owned reset and + invalidation rules remain authoritative. - The published summary contains one bounded approval state. Eligible runs first publish a conservative failed-until-confirmed state, then update the uniquely marked owned summary once after provider readback. Failure never rolls back the advisory review; strict mode returns nonzero while advisory mode remains nonfatal. Existing GitLab rules, groups, Code Owners, protected branches, and reauthentication stay authoritative. -- Self-review fixed receipt loss on partial reviews, version-order assumptions, - receipt parsing after cross-endpoint deduplication, stale receipt inheritance, - different-SHA approval claims, and provisional-summary truth. Ruff and strict - mypy pass; 148 posting/approval/suggestion tests and 15 public +- Self-review fixed version-order assumptions, different-SHA approval claims, + and provisional-summary truth. The final OCR correction subsequently removed + the unsafe managed-unapproval design and its receipt surface entirely. Ruff + and strict mypy pass; 148 posting/approval/suggestion tests and 15 public documentation/integration contracts pass. Towncrier 0.4.7 draft includes the default-on write and opt-out, and `git diff --check` passes. Roadmap and future backlog statuses remain unchanged because neither issue completes an existing @@ -171,10 +175,11 @@ independently read back. ### Release Lifecycle Checkpoint - The merged release PR is now the final repository mutation without claiming - future delivery. Authorization executes from that exact merge checkout, - validates tracked metadata from the same immutable ref, proves squash-tree - equivalence and parent identity, and requires every live strict `main` check - context from its exact GitHub App on the reviewed head SHA. + future delivery. Authorization executes from the protected reviewed base that + predates the release candidate, treats candidate head and merge commits as + bounded data, validates tracked metadata from the exact merge ref, proves + squash-tree equivalence and parent identity, and requires every live strict + `main` check context from its exact GitHub App on the reviewed head SHA. - Registry verification covers Python 3.12-3.14 and exact PyPI Integrity publisher/subject provenance. GitHub artifact attestations, annotated-tag target, exact Release metadata/assets, immutable status, and a deterministic @@ -200,6 +205,61 @@ independently read back. unchanged at this checkpoint because the lifecycle hardening changes process, not an outcome milestone or future-work activation trigger. +### Final OCR Review And Correction Checkpoint + +- The only final local OCR review covered + `bb8827148f13b17b209495788ac4f7b15573a168..fe88f8d78744847bc58b35129de5c9130cd46853` + with official OCR 1.9.1. It completed all 23 selected items in 39 minutes 5 + seconds with zero failed or waived items, returned 16 findings, and made 68 + mandatory `ocr_toolkit_evidence` calls. The private toolkit receipt records + the same 68 calls; result/stderr and `.review-context` artifacts retain owner- + only permissions, `.review-context` is absent from the reviewed diff, and no + GitLab posting command ran. +- Thirteen findings exposed valid boundary defects or the same root-cause + classes. Release authorization now executes from the protected pre-candidate + base. The GitHub API helper has a closed endpoint grammar, redirect-safe + bearer authentication, private same-directory temporary output, allowed- + status validation, and atomic replacement. Minor/major OCR promotion requires + chain-aware schema 2. Stable receipts reject unknown top-level and nested + fields; malformed registry provenance URLs fail closed; unhashable finding + categories degrade to not eligible; complete unified-diff replacements are + rejected; and unused approval-receipt parsing was removed. +- The two unapproval findings and the approval-without-durable-receipt finding + shared one architectural cause: GitLab's unapprove endpoint cannot receive the + reviewed SHA, so preflight and readback cannot close its destructive TOCTOU + gap. Automatic approval is therefore add-only. All unapproval and managed- + receipt runtime paths were removed instead of adding another compensating + state machine. +- Three suggestions were rejected after source and regression review. Python's + `binascii.Error` is already a `ValueError`, so existing malformed-base64 + handling covers both flagged decode sites. A user-authored exact issue-receipt + marker intentionally blocks closure as the documented anti-preemption, + fail-closed contract; it is not trusted as a successful receipt. +- Root-cause sibling audits covered URL parsers, release/security receipt + loaders, bounded HTTP helpers, and destructive provider writes. Public + approval/release/security documentation and `AGENTS.md`, project principles, + and execution pitfalls now encode the corrected boundaries. A direct + regression proves that schema-1 evidence cannot cross minor or major OCR + boundaries. No second OCR review or Codex Security run will be performed. +- The final post-correction gate runs from isolated Python 3.12.13 and passes + formatting, Ruff, strict mypy, Bandit with zero medium/high findings, 623 + tests plus 85 subtests, and 79.09% coverage. OCR manifest validation, + Towncrier 0.4.7 draft, changed-shell ShellCheck, workflow YAML parsing, + changed-public-content privacy checks across 31 files, and `git diff --check` + pass. Fresh `0.4.7.dev7` wheel and sdist pass Twine, canonical composition, + centralized SCM-version, zero-runtime-dependency, and Python `>=3.12,<3.15` + metadata checks. Separate Python 3.12 installs pass `pip check` and execute + the installed CLI/import under restricted `PATH` from a hostile shadow-package + working directory. Wheel SHA-256 is + `0582f8b1ed7623cec55aaeef289bde7d9ccda9c1b9b856d30eacb95e57508ac6`; + sdist SHA-256 is + `4bdcfc1a302e1f7392623b2c651bf8d747e8228891b74f14257479e8ab93dee6`. +- Final manual self-review removed the obsolete `ApprovalResult.managed` flag, + confirmed every workflow-used GitHub endpoint is represented by the closed + helper grammar, verified recovery binds the protected reviewed base, and + found no roadmap, backlog, or narrative status tail. Exactly one worktree is + present and all review/quality artifacts remain ignored and private. + ### OCR 1.9.0-1.9.1 Qualification Checkpoint - Canonical GitHub Actions run `31465539451` created separate open diff --git a/README.md b/README.md index 1a6c5f5..d39dc11 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ After every current review note publishes, the GitLab adapter can add a conservative approval bound to the exact reviewed source SHA. This write is enabled by default; set `OCR_AUTO_APPROVE=false` before upgrading when the bot must remain comment-only. GitLab approval rules and protected-branch policy -remain authoritative. +remain authoritative. The toolkit only adds an eligible approval; it never +removes an existing approval when a later review is ineligible or disabled. Project-wide accepted tradeoffs can be recorded separately in `.opencodereview/accepted-decisions.md`; the evidence collector supplies target-ref decisions to OCR and never lets a source change self-authorize its own review. See [Accepted project decisions](docs/configuration.md#accepted-project-decisions) for the entry format, inline marker convention, security boundary, and limitations. diff --git a/changelog.d/71.feature.md b/changelog.d/71.feature.md index b7bf274..8185c38 100644 --- a/changelog.d/71.feature.md +++ b/changelog.d/71.feature.md @@ -1,2 +1,2 @@ Add default-on `OCR_AUTO_APPROVE` for conservative, exact-SHA GitLab approval after every current review note publishes, with an explicit fail-closed opt-out and bounded status readback. -Limit eligibility to complete manifest-backed reviews with at most three low-severity style, documentation, or maintainability findings, and remove only a proven toolkit-managed approval when a later complete review becomes ineligible. +Limit eligibility to complete manifest-backed reviews with at most three low-severity style, documentation, or maintainability findings, while preserving every existing approval when a later review is ineligible or disabled. diff --git a/docs/codex/AGENT_EXECUTION_PITFALLS.md b/docs/codex/AGENT_EXECUTION_PITFALLS.md index 252e82e..da41cc3 100644 --- a/docs/codex/AGENT_EXECUTION_PITFALLS.md +++ b/docs/codex/AGENT_EXECUTION_PITFALLS.md @@ -44,6 +44,18 @@ This note records recurring execution mistake patterns discovered during real wo **Correction:** Make the release PR the final repository mutation while leaving external gates explicitly pending. Bind publication to the exact reviewed tree; create and independently read back an immutable machine-readable receipt after registry, provenance, tag, Release, hash, and install verification; close tracked issues only then. Recover partial publication from the original authorization and receipt without another commit or closure PR. +## Letting a release candidate execute its own authorizer + +**Failure mode:** The post-merge workflow checks out the candidate or merge +commit and runs its release-authorization helper from that tree. Exact tree, +parent, metadata, and check validation then appear rigorous even though the +candidate supplied the code deciding whether those checks pass. + +**Correction:** Run authorization code from the protected base SHA that +predates the release PR. Fetch candidate head, merge, metadata, checks, and +rules only as bounded data, bind recovery to the same reviewed base, and test +that workflow checkout independently from candidate inspection. + ## Updating status tables but not current-state prose **Failure mode:** The roadmap says a milestone is established while strategy and README still describe its implementation as a target or migration in progress. @@ -108,6 +120,30 @@ This note records recurring execution mistake patterns discovered during real wo **Correction:** Bound the read itself, including persisted configuration and sibling helper paths; stop producers after the allowed prefix plus one sentinel unit, and name the unit in the constant. Test a line without a newline, multibyte text, excessive Git or config input, and subprocess termination. +## Trusting a bounded HTTP response before the complete read commits + +**Failure mode:** A helper limits bytes and time but accepts arbitrary endpoint +paths, forwards a bearer header across redirects, or writes directly over a +trusted destination before curl and status validation finish. + +**Correction:** Use a closed endpoint grammar, transport-native redirect-safe +authentication, HTTPS-only redirect policy, and a private same-directory +temporary file. Replace the destination atomically only after transfer success +and an allowed status. Preserve the prior trusted file and remove partial output +on every failure path. + +## Automating a destructive provider write without a mutation-time identity guard + +**Failure mode:** A preflight read confirms the reviewed SHA, then automation +deletes, resets, or withdraws state through an endpoint that cannot receive that +SHA. The provider may advance between the read and write, so later readback can +detect but cannot undo a destructive TOCTOU mutation. + +**Correction:** Require the immutable reviewed identity in the mutation request +itself. If the provider endpoint has no such guard, do not automate the +destructive transition; preserve existing state and rely on explicit +provider-owned reset or invalidation policy. + ## Trusting toolkit-created evidence on reload **Failure mode:** Collection validates records, but reload assigns snapshots, deltas, or diagnostics directly. A replaced private artifact bypasses the original redaction, size, or cross-reference checks. @@ -116,6 +152,16 @@ This note records recurring execution mistake patterns discovered during real wo **Correction:** Validate, bound, normalize, redact, and cross-check every persisted field on every read. Test missing references, oversized nested delta values, secrets, control characters, hard links, and schema/type mismatches. +## Accepting extension fields in a security receipt + +**Failure mode:** Recovery compares the known fields of a persisted release or +security receipt but silently accepts extra top-level or nested keys. A future +or attacker-controlled shape is then treated as the old authorization contract. + +**Correction:** Define and validate an exact key set at every receipt object +level before comparing values. Add regressions for unknown top-level and nested +fields, malformed optional values, and type-confused identities. + ## Clearing only process-level Git overrides **Failure mode:** A primary Git reader clears `GIT_DIR` and object-store variables, but repository replacement refs, global/system config, or a sibling posting helper still changes which objects a reviewed SHA names. diff --git a/docs/configuration.md b/docs/configuration.md index 6170873..7e40d95 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,10 +59,12 @@ Posting requires `GITLAB_API_TOKEN`, `CI_SERVER_URL`, `CI_PROJECT_ID`, and `CI_M `OCR_AUTO_APPROVE` defaults to `true` and adds a formal GitLab approval after a complete review publishes. It accepts `true`, `1`, `yes`, or `on`; set `false`, -`0`, `no`, or `off` to disable all approval and unapproval management for that -run. An empty value uses the enabled default. Any other value fails closed to -disabled and emits a bounded diagnostic without printing the value. Disabled -runs preserve an earlier toolkit-managed approval. +`0`, `no`, or `off` to disable the approval attempt for that run. An empty value +uses the enabled default. Any other value fails closed to disabled and emits a +bounded diagnostic without printing the value. The toolkit never removes an +existing approval. Ineligible, partial, skipped, legacy, and disabled runs make +no approval write, so project-owned reset and invalidation rules remain the only +mechanism for withdrawing an earlier approval. The initial policy is fixed: zero findings, or at most three findings whose severity is exactly `low` and category is exactly `style`, `documentation`, or diff --git a/docs/engineering/project_principles.md b/docs/engineering/project_principles.md index 9659a39..0deb9b1 100644 --- a/docs/engineering/project_principles.md +++ b/docs/engineering/project_principles.md @@ -25,6 +25,7 @@ This is the short index of stable cross-cutting engineering rules for Open Code 19. Separate repository authorization from external delivery without duplicating repository work. The release PR is the final repository mutation and records only reviewed repository truth plus pending external gates. Exact-tree authorization, an immutable machine-readable release receipt, independent registry/tag/Release/provenance/hash/install readback, and issue closure complete delivery after merge without a redundant closure PR. 20. When an architectural milestone becomes implemented, update narrative current-state documentation in the same closure as plan, roadmap, and backlog status. Strategy and README must not continue describing the shipped architecture as a transition or target. 21. Archive older completed execution plans by stable release tag only after their receipts are complete. Maintain a validated index that lets future agents find the original decisions and evidence without turning `PLANS.md` into the permanent release-history database. +22. Execute security-sensitive release authorization from protected policy that predates the candidate. Candidate heads and merge commits are evidence to validate, not executable authority over their own publication. ## Boundary Invariants @@ -39,6 +40,9 @@ This is the short index of stable cross-cutting engineering rules for Open Code 9. Profile realistic bounded data by separating cold-start validation from steady-state requests; optimize the measured bottleneck rather than protocol dispatch by assumption. 10. Before implementing a parser or trust boundary, record the grammar, normalization and degradation policies, budget units, inherited-process state, and adversarial fixtures in the active plan or tests. 11. Missing evidence supports a negative conclusion only when the applicable component, domain, and scope explicitly report complete coverage; absent, partial, runtime-dependent, and unavailable coverage remain unknown. +12. Treat bounded HTTP output as untrusted until a closed endpoint allowlist, redirect-safe authentication, transfer result, allowed status, and same-directory atomic replacement all succeed. A size limit alone does not make a response trusted. +13. Automate a destructive provider write only when the provider binds the mutation itself to the validated immutable identity. A preflight read or post-write readback cannot close a time-of-check/time-of-use gap; when no mutation-time guard exists, preserve state and leave withdrawal to provider-owned policy. +14. Give persisted security and release receipts exact closed schemas at every object level. Reject unknown fields and malformed nested shapes before comparing identity or authorizing recovery. ## Documentation Ownership diff --git a/docs/gitlab.md b/docs/gitlab.md index e2f010d..1872079 100644 --- a/docs/gitlab.md +++ b/docs/gitlab.md @@ -27,7 +27,10 @@ current notes publish, it waits for GitLab diff and approval synchronization, verifies the current MR head against the reviewed SHA, submits that exact SHA, and confirms only the authenticated toolkit user's approval through bounded readback. Set `OCR_AUTO_APPROVE=false` for a comment-only bot or before upgrading -an integration whose approval rules have not granted the bot permission. +an integration whose approval rules have not granted the bot permission. This +transaction is add-only: an ineligible or disabled later run never removes an +existing approval. Configure GitLab's own reset or invalidation policy if +approvals must be withdrawn after the source branch changes. Repeated reviews have a reviewer-controlled lifecycle rather than appending the same notes indefinitely. Untouched OCR-only notes are replaced after a successful run, human-touched discussions are preserved, and `/ocr suppress` or `/ocr resolve` controls future matching findings. Read [GitLab review operations](operations.md) for the complete state machine, deduplication boundaries, posting modes, permissions, limits, and failure semantics. diff --git a/docs/operations.md b/docs/operations.md index deff003..9966663 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -42,8 +42,8 @@ non-null `patch_id_sha`. It then passes that exact SHA to GitLab's approve API and confirms the authenticated user in approval readback. A moved head is a normal `skipped` result and is never retried against the new commit. -Approve, unapprove, and summary-update writes are not retried after timeout, -connection loss, 5xx, or another ambiguous response. GitLab remains +Approve and summary-update writes are not retried after timeout, connection +loss, 5xx, or another ambiguous response. GitLab remains authoritative for eligible approvers, required groups, Code Owners, protected-branch rules, and password or SAML reauthentication. A rejected or failed approval never rolls back the already published advisory review. @@ -53,12 +53,14 @@ The summary records exactly one bounded state: `approved`, `not eligible`, approval-management failure leaves the published review successful but visibly failed; with `OCR_STRICT_POSTING=true`, it also returns a nonzero exit code. -When a later complete authoritative review is no longer eligible, the toolkit -may unapprove only the authenticated user and only when an owned versioned -summary receipt proves that this toolkit managed the earlier approval. It never -calls `reset_approvals` or removes a human approval. Partial, skipped, legacy, -and disabled runs preserve an earlier managed approval. Human discussion replies -remain ownership boundaries for notes but do not independently block approval. +The transaction is deliberately add-only because GitLab's unapprove endpoint +cannot bind removal to an immutable reviewed SHA at mutation time. The toolkit +therefore never removes an existing approval, even when the authenticated bot +user approved earlier. Ineligible, partial, skipped, legacy, and disabled runs +do not make an approval write. Configure GitLab's project-owned reset or +invalidation policy when approvals must be withdrawn after new commits. Human +discussion replies remain ownership boundaries for notes but do not +independently block approval. ## Discussion lifecycle diff --git a/docs/release.md b/docs/release.md index 1d385bb..8bcbf9d 100644 --- a/docs/release.md +++ b/docs/release.md @@ -21,7 +21,22 @@ The delivery sequence is: The release pull request is the final repository mutation. It owns repository-side preparation: stable and next version markers, deterministic source epoch, tracked release authorization metadata, generated Towncrier changelog, release notes, and reconciliation of `PLANS.md`, the execution-history index, roadmap, backlog, strategy, and README where applicable. It lists external checks as pending and must not claim that registry files, provenance, tag, immutable Release, receipt, or installs already exist. -The post-merge workflow binds the squash merge to the exact reviewed release-head tree and the live `main` ruleset's required checks. It publishes or exact-hash-verifies the stable artifacts, verifies registry and GitHub provenance plus supported-Python installs, and creates `ocr-toolkit.release-receipt/v1` before publishing the GitHub Release. The receipt deliberately marks Release asset self-readback as pending; the workflow then downloads the complete asset set, publishes the draft, requires GitHub's immutable state, and only afterward records idempotent issue receipts and closes the tracked issues. Do not mark the release objective complete after feature merge, development publication, or release-PR preparation. If the owner explicitly defers stable publication, keep the release-required plan active or blocked and record the reason, target stable version, completed checkpoints, and exact resume action. +The post-merge workflow executes its authorizer from the protected base SHA that +predates the release PR; candidate head and squash-merge commits are inspected +only as bounded data and cannot supply the code that authorizes themselves. +Authorization then binds the squash merge to the exact reviewed release-head +tree, its exact protected base parent, and the live `main` ruleset's required +checks. It publishes or exact-hash-verifies the stable artifacts, verifies +registry and GitHub provenance plus supported-Python installs, and creates +`ocr-toolkit.release-receipt/v1` before publishing the GitHub Release. The +receipt deliberately marks Release asset self-readback as pending; the workflow +then downloads the complete asset set, publishes the draft, requires GitHub's +immutable state, and only afterward records idempotent issue receipts and closes +the tracked issues. Do not mark the release objective complete after feature +merge, development publication, or release-PR preparation. If the owner +explicitly defers stable publication, keep the release-required plan active or +blocked and record the reason, target stable version, completed checkpoints, +and exact resume action. ## Development builds @@ -62,4 +77,11 @@ The immutable receipt carries the release PR, reviewed base/head/merge/tree, ori `PLANS.md` keeps the just-prepared release cycle so its pending gates and later external receipt remain immediately discoverable from the tag and tracked issues. During the next release PR, move the previously retained externally reconciled cycle without rewriting it into `docs/engineering/execution_history/releases.md`, add or update the corresponding stable-tag row in the [execution-history index](engineering/execution_history/README.md), and validate every archive anchor. Preserve dates inside archived plans; stable tags, not calendar years, are the lookup keys. -Recovery dispatch is bound to the original release PR, version, merge commit, and reviewed head. It accepts only exact registry bytes and the existing immutable receipt's release identity. If only issue commenting or closure failed, recovery reuses the exact GitHub Actions-owned receipt comment, accepts an already-completed issue, and does not change repository files, tag, or immutable Release assets. A user-authored marker cannot preempt that bot-owned receipt. +Recovery dispatch is bound to the original release PR, version, merge commit, +reviewed head, and protected reviewed base. It executes the same trusted-base +authorizer, accepts only exact registry bytes and the existing immutable +receipt's closed release identity, and rejects unknown receipt fields. If only +issue commenting or closure failed, recovery reuses the exact GitHub +Actions-owned receipt comment, accepts an already-completed issue, and does not +change repository files, tag, or immutable Release assets. A user-authored +marker cannot preempt that bot-owned receipt. diff --git a/docs/security.md b/docs/security.md index fd1caca..53d19ab 100644 --- a/docs/security.md +++ b/docs/security.md @@ -16,9 +16,10 @@ The toolkit bridges four trust domains: repository content, OCR and its LLM/MCP - GitLab notes enforce both UTF-8 byte limits and Python character limits. - Non-idempotent API writes are not blindly retried. - Automatic approval is bound to the exact reviewed MR head after GitLab diff - synchronization and bounded readback. Unapproval is limited to the current - toolkit user and requires an owned versioned receipt; human approvals are - never reset or removed. + synchronization and bounded readback. The transaction is add-only: because + GitLab cannot bind unapproval to an immutable reviewed SHA, the toolkit never + removes an existing approval. Project-owned approval reset and invalidation + rules remain authoritative. - Markers, fingerprints, snapshots, and rollback logic constrain repeated runs. - Human replies are ownership boundaries: automation must not rewrite or resolve a discussion after a human takes part. - Merge-request source SHA and merge-result SHA remain distinct. @@ -40,6 +41,13 @@ toolkit does not bypass them. Pin Open Code Review `v1.9.1` and verify its checksum. Pin Python dependencies through `uv.lock` and GitHub Actions by immutable commit SHA. MCP stdio commands and remote endpoints are privileged configuration; allow only reviewed servers and tools. The [OCR compatibility policy](compatibility.md) requires double-source asset digest verification, bounded downloads, an executed Linux contract probe, and protected PR/release gates; qualification automation never writes directly to `main` or promotes an ambiguous release. +Stable-release authorization executes from the protected base SHA that predates +the release candidate. Candidate and merge commits are bounded data rather than +the source of their own authorizer. GitHub API reads use a closed endpoint +allowlist, HTTPS-only redirect policy, redirect-safe bearer authentication, and +atomic replacement only after transfer and status validation. Persisted release +receipts accept only their exact versioned top-level and nested schemas. + Remote MCP is HTTPS-only, forbids URL userinfo and fragments, and never logs endpoint URLs or header values. Put credentials in protected/masked CI variables and reference them through `headers_from`; literal credential-like headers fail closed. OCR expands the resulting `$VARIABLE` at connection time. Full browser OAuth, PKCE, refresh-token persistence, tenant binding, and revocation remain conditional on a named supported-provider requirement; use a reviewed stdio OAuth proxy when those flows are required today. All toolkit-owned Git plumbing ignores process-level repository/object-store overrides, global and system Git configuration, and replacement refs before it derives evidence or remaps an inline finding. Existing OCR configuration is treated as hostile persisted input: reads are descriptor-based, single-link, and byte-bounded before JSON parsing. diff --git a/scripts/bounded_github_api.sh b/scripts/bounded_github_api.sh index 48afa35..ecafb21 100755 --- a/scripts/bounded_github_api.sh +++ b/scripts/bounded_github_api.sh @@ -1,6 +1,7 @@ #!/bin/sh # Read one GitHub REST response with an enforced byte and time boundary. set -eu +umask 077 endpoint=${1:?GitHub API endpoint is required} output=${2:?output path is required} @@ -9,13 +10,30 @@ expected_statuses=${4:-200} max_bytes=${5:-1048576} accept=${6:-application/vnd.github+json} -case "${endpoint}" in - repos/*) ;; - *) echo "unsupported GitHub API endpoint" >&2; exit 2 ;; -esac +python3 - "${endpoint}" <<'PY' +import re +import sys + +endpoint = sys.argv[1] +repository = r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+" +patterns = ( + rf"repos/{repository}/pulls/[1-9][0-9]*", + rf"repos/{repository}/commits/[0-9a-f]{{40}}", + rf"repos/{repository}/commits/[0-9a-f]{{40}}/check-runs\?filter=latest&per_page=100", + rf"repos/{repository}/contents/\.release-metadata\.json\?ref=[0-9a-f]{{40}}", + rf"repos/{repository}/rules/branches/main", + rf"repos/{repository}/issues/[1-9][0-9]*", + rf"repos/{repository}/issues/[1-9][0-9]*/comments\?per_page=100&page=[1-5]", + rf"repos/{repository}/issues/[1-9][0-9]*/comments\?per_page=1&page=501", + rf"repos/{repository}/releases/tags/v[0-9]+(?:\.[0-9]+)+", + rf"repos/{repository}/releases/assets/[1-9][0-9]*", +) +if not any(re.fullmatch(pattern, endpoint) for pattern in patterns): + raise SystemExit("unsupported GitHub API endpoint") +PY case "${authentication}" in authenticated) - set -- --header "Authorization: Bearer ${GH_TOKEN:?GH_TOKEN is required}" + set -- --oauth2-bearer "${GH_TOKEN:?GH_TOKEN is required}" ;; anonymous) set -- @@ -37,12 +55,19 @@ case "${accept}" in *) echo "unsupported GitHub API media type" >&2; exit 2 ;; esac +output_directory=$(dirname -- "${output}") +temporary_output=$(mktemp "${output_directory}/.bounded-github-api.XXXXXX") +cleanup() { + rm -f -- "${temporary_output}" +} +trap cleanup EXIT HUP INT TERM + if ! http_status=$(curl --silent --show-error \ --location --connect-timeout 10 --max-time 60 --max-filesize "${max_bytes}" \ --proto '=https' --proto-redir '=https' \ --header "Accept: ${accept}" \ --header "X-GitHub-Api-Version: ${GITHUB_API_VERSION:-2022-11-28}" \ - "$@" --output "${output}" --write-out '%{http_code}' \ + "$@" --output "${temporary_output}" --write-out '%{http_code}' \ "https://api.github.com/${endpoint}"); then echo "bounded GitHub API read failed: ${endpoint}" >&2 exit 1 @@ -51,4 +76,6 @@ case ",${expected_statuses}," in *,"${http_status}",*) ;; *) echo "unexpected GitHub API status ${http_status}: ${endpoint}" >&2; exit 1 ;; esac +mv -- "${temporary_output}" "${output}" +trap - EXIT HUP INT TERM printf '%s\n' "${http_status}" diff --git a/scripts/ocr_compat.py b/scripts/ocr_compat.py index 6b2551e..c729118 100644 --- a/scripts/ocr_compat.py +++ b/scripts/ocr_compat.py @@ -1061,6 +1061,8 @@ def prepare_update( transition = _release_transition(comparison, candidate) if transition is None: _fail("candidate evidence chain is not a contiguous release sequence") + if transition != "patch" and item.get("schema_version") != 2: + _fail("minor and major promotions require chain-aware evidence schema 2") if item.get("result") != "compatible": _fail(f"candidate evidence does not qualify {version} as compatible") schema_version = item.get("schema_version") diff --git a/scripts/release_authorization.py b/scripts/release_authorization.py index 4915079..d44073c 100644 --- a/scripts/release_authorization.py +++ b/scripts/release_authorization.py @@ -202,6 +202,7 @@ def authorize_release( requested_version: str = "", requested_commit: str = "", requested_head: str = "", + requested_base: str = "", ) -> dict[str, str]: """Return safe workflow outputs for one exact repository-owned release merge.""" @@ -246,6 +247,8 @@ def authorize_release( raise AuthorizationError("requested commit does not match the release pull request") if requested_head and requested_head != head_sha: raise AuthorizationError("requested head does not match the release pull request") + if requested_base and requested_base != base_sha: + raise AuthorizationError("requested base does not match the release pull request") head_tree, _head_parents = _commit_metadata(head_commit_payload, head_sha, "head") merge_tree, merge_parents = _commit_metadata(merge_commit_payload, commit, "merge") @@ -283,6 +286,7 @@ def main() -> int: parser.add_argument("--requested-version", default="") parser.add_argument("--requested-commit", default="") parser.add_argument("--requested-head", default="") + parser.add_argument("--requested-base", default="") parser.add_argument("--github-output", type=Path, required=True) args = parser.parse_args() @@ -307,6 +311,7 @@ def load_object(path: Path, description: str) -> dict[str, Any]: args.requested_version, args.requested_commit, args.requested_head, + args.requested_base, ) with args.github_output.open("a", encoding="utf-8") as output: for key, value in outputs.items(): diff --git a/scripts/release_receipt.py b/scripts/release_receipt.py index 7981214..19c6ad0 100644 --- a/scripts/release_receipt.py +++ b/scripts/release_receipt.py @@ -164,6 +164,8 @@ def validate_receipt( artifacts=artifacts, python_minors=python_minors, ) + if set(payload) != set(expected): + raise ReceiptError("existing release receipt has an unsupported schema shape") for key in ( "schema_version", "version", @@ -179,7 +181,7 @@ def validate_receipt( if payload.get(key) != expected[key]: raise ReceiptError(f"existing release receipt field {key!r} does not match") workflow = payload.get("workflow") - if not isinstance(workflow, dict): + if not isinstance(workflow, dict) or set(workflow) != {"run_id", "run_attempt"}: raise ReceiptError("existing release receipt workflow identity is invalid") run_id = workflow.get("run_id") run_attempt = workflow.get("run_attempt") diff --git a/scripts/testpypi_preview.py b/scripts/testpypi_preview.py index 8de7c1d..80f1b3b 100644 --- a/scripts/testpypi_preview.py +++ b/scripts/testpypi_preview.py @@ -141,9 +141,9 @@ def artifact_manifest( raise PreviewError(f"{filename} has no download URL") if not isinstance(provenance_url, str): raise PreviewError(f"{filename} has no provenance URL") - parsed = urlsplit(url) - provenance = urlsplit(provenance_url) try: + parsed = urlsplit(url) + provenance = urlsplit(provenance_url) port = parsed.port provenance_port = provenance.port except ValueError as exc: diff --git a/src/ocr_toolkit/posting/approval.py b/src/ocr_toolkit/posting/approval.py index 4b2c733..85d355b 100644 --- a/src/ocr_toolkit/posting/approval.py +++ b/src/ocr_toolkit/posting/approval.py @@ -29,7 +29,6 @@ class ApprovalResult: status: ApprovalStatus reason: str - managed: bool = False @dataclass(frozen=True, slots=True) @@ -37,7 +36,6 @@ class ApprovalEligibility: """Policy conclusion before provider state is consulted.""" eligible: bool - may_unapprove: bool result: ApprovalResult @@ -57,17 +55,9 @@ def evaluate_approval_policy( else "disabled by OCR_AUTO_APPROVE" ) return ApprovalEligibility( - False, False, ApprovalResult(ApprovalStatus.DISABLED, reason), ) - authoritative = bool( - outcome.manifest_present - and outcome.kind == "clean" - and not outcome.budget_exceeded - and not outcome.failed_count - and not outcome.waived_count - ) if not outcome.manifest_present: reason = "the OCR result has no authoritative coverage manifest" elif outcome.kind != "clean" or outcome.budget_exceeded: @@ -86,7 +76,6 @@ def evaluate_approval_policy( if reason: return ApprovalEligibility( False, - authoritative, ApprovalResult(ApprovalStatus.NOT_ELIGIBLE, reason), ) @@ -96,16 +85,14 @@ def evaluate_approval_policy( if severity != "low": return ApprovalEligibility( False, - True, ApprovalResult( ApprovalStatus.NOT_ELIGIBLE, "a finding had a blocking or malformed severity", ), ) - if category not in ALLOWED_CATEGORIES: + if not isinstance(category, str) or category not in ALLOWED_CATEGORIES: return ApprovalEligibility( False, - True, ApprovalResult( ApprovalStatus.NOT_ELIGIBLE, "a finding had a blocking or malformed category", @@ -114,7 +101,6 @@ def evaluate_approval_policy( return ApprovalEligibility( True, - False, ApprovalResult( ApprovalStatus.SKIPPED, "awaiting post-publication SHA verification", diff --git a/src/ocr_toolkit/posting/gitlab.py b/src/ocr_toolkit/posting/gitlab.py index a183185..5fbced8 100644 --- a/src/ocr_toolkit/posting/gitlab.py +++ b/src/ocr_toolkit/posting/gitlab.py @@ -539,17 +539,6 @@ def approve_merge_request(config: GitLabConfig, sha: str) -> GitLabWriteResult: ) -def unapprove_merge_request(config: GitLabConfig) -> GitLabWriteResult: - """Remove only the authenticated user's approval without retrying.""" - - return api_write_url_detailed( - url=f"{config.api_base}/unapprove", - api_token=config.api_token, - auth_header=config.auth_header, - data={}, - ) - - def delete_discussion_note(config: GitLabConfig, discussion_id: str, note_id: int) -> bool: """Delete a note inside a merge request discussion thread.""" diff --git a/src/ocr_toolkit/posting/gitlab_approval.py b/src/ocr_toolkit/posting/gitlab_approval.py index 89b0466..069c833 100644 --- a/src/ocr_toolkit/posting/gitlab_approval.py +++ b/src/ocr_toolkit/posting/gitlab_approval.py @@ -14,7 +14,6 @@ ApprovalResult, ApprovalStatus, ) -from ocr_toolkit.posting.markers import ManagedApprovalReceipt SYNC_ATTEMPTS = 10 SYNC_INTERVAL_SECONDS = 2.0 @@ -23,10 +22,9 @@ @dataclass(frozen=True, slots=True) class ApprovalExecution: - """Final provider result and any ownership receipt that must survive.""" + """Final provider result after exact-SHA synchronization and readback.""" result: ApprovalResult - receipt: ManagedApprovalReceipt | None = None @dataclass(frozen=True, slots=True) @@ -175,20 +173,15 @@ def execute_approval( config: gitlab.GitLabConfig, eligibility: ApprovalEligibility, expected_sha: str, - prior_receipt: ManagedApprovalReceipt | None, *, attempts: int = SYNC_ATTEMPTS, interval_seconds: float = SYNC_INTERVAL_SECONDS, sleep: Callable[[float], None] = time.sleep, ) -> ApprovalExecution: - """Apply the policy after publication while preserving human approvals.""" + """Apply exact-SHA approval without ever removing an existing approval.""" - if prior_receipt is not None and prior_receipt.user_id != config.current_user_id: - prior_receipt = None - if eligibility.result.status is ApprovalStatus.DISABLED: - return ApprovalExecution(eligibility.result, prior_receipt) - if not eligibility.eligible and (prior_receipt is None or not eligibility.may_unapprove): - return ApprovalExecution(eligibility.result, prior_receipt) + if not eligibility.eligible: + return ApprovalExecution(eligibility.result) state, synchronization_result = wait_for_synchronized_approval_state( config, @@ -201,67 +194,19 @@ def execute_approval( return ApprovalExecution( synchronization_result or ApprovalResult(ApprovalStatus.FAILED, "GitLab synchronization failed"), - prior_receipt, ) - if eligibility.eligible: - if state.own_approved: - if prior_receipt is None: - return ApprovalExecution( - ApprovalResult( - ApprovalStatus.SKIPPED, - "the toolkit user already approved without a managed receipt", - ) - ) - if prior_receipt.reviewed_sha != expected_sha: - return ApprovalExecution( - ApprovalResult( - ApprovalStatus.SKIPPED, - "the existing managed approval was not bound to the reviewed commit", - ), - prior_receipt, - ) - return ApprovalExecution( - ApprovalResult( - ApprovalStatus.APPROVED, - "the toolkit user's approval is confirmed for the reviewed commit", - managed=True, - ), - ManagedApprovalReceipt(config.current_user_id or 0, expected_sha), - ) - - write = gitlab.approve_merge_request(config, expected_sha) - if not write.posted: - return ApprovalExecution(_write_rejection(write, "approve")) - confirmed, confirmation_error = wait_for_synchronized_approval_state( - config, - expected_sha, - attempts=1, - interval_seconds=0, - sleep=sleep, - ) - if confirmed is None or not confirmed.own_approved: - return ApprovalExecution( - confirmation_error - or ApprovalResult( - ApprovalStatus.FAILED, - "GitLab approval readback did not confirm the toolkit user", - ), - ) + if state.own_approved: return ApprovalExecution( ApprovalResult( - ApprovalStatus.APPROVED, - "GitLab confirmed the toolkit user's exact-SHA approval", - managed=True, - ), - ManagedApprovalReceipt(config.current_user_id or 0, expected_sha), + ApprovalStatus.SKIPPED, + "the toolkit user already approved; no approval state was changed", + ) ) - if not state.own_approved: - return ApprovalExecution(eligibility.result) - write = gitlab.unapprove_merge_request(config) + write = gitlab.approve_merge_request(config, expected_sha) if not write.posted: - return ApprovalExecution(_write_rejection(write, "unapprove"), prior_receipt) + return ApprovalExecution(_write_rejection(write, "approve")) confirmed, confirmation_error = wait_for_synchronized_approval_state( config, expected_sha, @@ -269,13 +214,17 @@ def execute_approval( interval_seconds=0, sleep=sleep, ) - if confirmed is None or confirmed.own_approved: + if confirmed is None or not confirmed.own_approved: return ApprovalExecution( confirmation_error or ApprovalResult( ApprovalStatus.FAILED, - "GitLab unapproval readback did not confirm removal", + "GitLab approval readback did not confirm the toolkit user", ), - prior_receipt, ) - return ApprovalExecution(eligibility.result) + return ApprovalExecution( + ApprovalResult( + ApprovalStatus.APPROVED, + "GitLab confirmed the toolkit user's exact-SHA approval", + ) + ) diff --git a/src/ocr_toolkit/posting/markers.py b/src/ocr_toolkit/posting/markers.py index 5743466..84f348e 100644 --- a/src/ocr_toolkit/posting/markers.py +++ b/src/ocr_toolkit/posting/markers.py @@ -4,7 +4,6 @@ import hashlib import re -from dataclasses import dataclass from typing import TYPE_CHECKING, Any from ocr_toolkit.posting.comments import clean_text, code_text, comment_line, line_number @@ -15,24 +14,6 @@ MARKER = "" -SUMMARY_RUN_MARKER_RE = re.compile(r"") - - -MANAGED_APPROVAL_RE = re.compile( - r"" -) - - -MANAGED_APPROVAL_SUMMARY_RE = re.compile( - r"\A\r?\n" - r"\r?\n" - r"\r?\n" - r"## Open Code Review(?:\r?\n|\Z)" -) - - MARKER_WITH_FINGERPRINT_RE = re.compile( r"^" ) @@ -46,14 +27,6 @@ FINGERPRINT_LEN = 32 # hex characters (= 16 raw bytes from blake2b) -@dataclass(frozen=True, slots=True) -class ManagedApprovalReceipt: - """Proof that this toolkit user managed approval for one reviewed SHA.""" - - user_id: int - reviewed_sha: str - - def build_summary_run_marker(run_id: str) -> str: """Render a unique bounded marker used to find the current summary note.""" @@ -62,27 +35,6 @@ def build_summary_run_marker(run_id: str) -> str: return f"" -def build_managed_approval_receipt(receipt: ManagedApprovalReceipt) -> str: - """Render a versioned marker proving toolkit-managed approval ownership.""" - - if receipt.user_id <= 0 or not re.fullmatch(r"[0-9a-f]{40}", receipt.reviewed_sha): - raise ValueError("managed approval receipt fields are invalid") - return ( - "" - ) - - -def managed_approval_receipt_from_body(body: str) -> ManagedApprovalReceipt | None: - """Parse one valid managed-approval receipt from an owned summary body.""" - - match = MANAGED_APPROVAL_SUMMARY_RE.match(body) - if match is None or len(MANAGED_APPROVAL_RE.findall(body)) != 1: - return None - raw_user_id, reviewed_sha = match.groups() - return ManagedApprovalReceipt(int(raw_user_id), reviewed_sha) - - def _digest_payload(parts: list[str], digest_size: int = FINGERPRINT_LEN // 2) -> str: """Return a blake2b digest for normalized fingerprint fields.""" diff --git a/src/ocr_toolkit/posting/snapshot.py b/src/ocr_toolkit/posting/snapshot.py index ebfeb53..2514183 100644 --- a/src/ocr_toolkit/posting/snapshot.py +++ b/src/ocr_toolkit/posting/snapshot.py @@ -18,14 +18,12 @@ ) from ocr_toolkit.posting.markers import ( OCR_REPLY_COMMAND_RE, - ManagedApprovalReceipt, author_id_from_note, comment_fingerprint, comment_fingerprint_candidates, fingerprint_from_marker, is_diff_note, is_own_bot_note, - managed_approval_receipt_from_body, ) from ocr_toolkit.posting.settings import post_mode, strict_posting @@ -52,9 +50,6 @@ class BotCommentRefs: suppressed_fingerprints: set[str] = field(default_factory=set) # Discussions that the bot should resolve after successful posting. discussions_to_resolve: list[str] = field(default_factory=list) - # Accepted only from an owned plain summary note. Ambiguous receipts are - # discarded so unapproval cannot be authorized by conflicting history. - managed_approval_receipt: ManagedApprovalReceipt | None = None def cleanup_drafts_created_by_this_run(config: GitLabConfig, draft_note_ids: list[int]) -> None: @@ -283,7 +278,6 @@ def collect_previous_bot_comment_refs( preserve_human_touched=preserve_human_touched, ) - managed_receipts: set[ManagedApprovalReceipt] = set() for note in plain_notes: if not isinstance(note, dict): continue @@ -295,15 +289,6 @@ def collect_previous_bot_comment_refs( if not isinstance(note_id, int): continue - # Parse ownership before de-duplicating `/notes` against - # `/discussions`: GitLab may expose the same general note through both - # collections even though the approval receipt belongs to the plain - # toolkit summary rather than an inline position. - if not is_diff_note(note): - receipt = managed_approval_receipt_from_body(str(note.get("body") or "")) - if receipt is not None and receipt.user_id == config.current_user_id: - managed_receipts.add(receipt) - # GET /notes can include diff notes that also appear in # /discussions. GitLab does not always expose enough shape in # /notes to classify them with is_diff_note(), so skip every own @@ -315,15 +300,6 @@ def collect_previous_bot_comment_refs( refs.all_plain_note_ids.append(note_id) refs.plain_note_ids.append(note_id) - if len(managed_receipts) == 1: - refs.managed_approval_receipt = managed_receipts.pop() - elif len(managed_receipts) > 1: - print( - "Multiple toolkit-managed approval receipts were found; " - "automatic unapproval is disabled for this run.", - file=sys.stderr, - ) - draft_notes: list[Any] = [] if post_mode() == "draft": fetched_draft_notes = api_get_paginated(config, "/draft_notes", max_pages=50) diff --git a/src/ocr_toolkit/posting/suggestions.py b/src/ocr_toolkit/posting/suggestions.py index e0d4309..d5aed90 100644 --- a/src/ocr_toolkit/posting/suggestions.py +++ b/src/ocr_toolkit/posting/suggestions.py @@ -147,8 +147,13 @@ def _replacement_shape_omission(replacement: str) -> SuggestionOmission | None: return SuggestionOmission.QUICK_ACTION nonblank = [line for line in lines if line.strip()] - if nonblank and all(line.startswith(("+", "-")) for line in nonblank): - return SuggestionOmission.DIFF_PREFIXED + if nonblank: + diff_prefixed = all(line.startswith(("+", "-")) for line in nonblank) + unified_diff_markers = sum( + line.startswith(("diff --git ", "index ", "--- ", "+++ ", "@@")) for line in nonblank + ) + if diff_prefixed or unified_diff_markers >= 2: + return SuggestionOmission.DIFF_PREFIXED return None diff --git a/src/ocr_toolkit/posting/workflow.py b/src/ocr_toolkit/posting/workflow.py index 93e7998..c55cd9f 100644 --- a/src/ocr_toolkit/posting/workflow.py +++ b/src/ocr_toolkit/posting/workflow.py @@ -59,9 +59,7 @@ ) from ocr_toolkit.posting.gitlab_approval import ApprovalExecution, execute_approval from ocr_toolkit.posting.markers import ( - ManagedApprovalReceipt, annotate_comment_fingerprints, - build_managed_approval_receipt, build_summary_run_marker, is_own_bot_note, ) @@ -134,17 +132,10 @@ def mr_head_sha() -> str: return clean_text(os.environ.get("CI_MERGE_REQUEST_SOURCE_BRANCH_SHA", "")) -def summary_with_receipts( - body: str, - run_id: str, - receipt: ManagedApprovalReceipt | None, -) -> str: - """Attach bounded hidden identity and approval ownership to one summary.""" +def summary_with_run_marker(body: str, run_id: str) -> str: + """Attach one bounded hidden transaction identity to the summary.""" - markers = [build_summary_run_marker(run_id)] - if receipt is not None: - markers.append(build_managed_approval_receipt(receipt)) - return "\n".join([*markers, body]) + return "\n".join((build_summary_run_marker(run_id), body)) def find_current_summary_note(config: GitLabConfig, run_id: str) -> int | None: @@ -214,17 +205,14 @@ def finalize_review_approval( config, eligibility, reviewed_commit, - previous_refs.managed_approval_receipt, ) - final_body = summary_with_receipts( + final_body = summary_with_run_marker( render_summary(execution.result), run_id, - execution.receipt, ) - provisional_body = summary_with_receipts( + provisional_body = summary_with_run_marker( render_summary(provisional_approval_result(eligibility)), run_id, - previous_refs.managed_approval_receipt, ) summary_updated = final_body == provisional_body or replace_current_summary( config, @@ -237,7 +225,6 @@ def finalize_review_approval( ApprovalStatus.FAILED, "the published approval status could not be safely confirmed", ), - execution.receipt or previous_refs.managed_approval_receipt, ) print( "Automatic approval summary update failed after review publication.", @@ -700,10 +687,9 @@ def render_no_comments_summary(approval_result: ApprovalResult) -> str: emoji=emoji, ) - body = summary_with_receipts( + body = summary_with_run_marker( render_no_comments_summary(provisional_approval_result(approval_eligibility)), summary_run_id, - previous_bot_comment_refs.managed_approval_receipt, ) response = post_review_note_bounded( config, @@ -883,10 +869,9 @@ def render_findings_summary(approval_result: ApprovalResult) -> str: summary_response = post_review_note_bounded( config, "", - summary_with_receipts( + summary_with_run_marker( render_findings_summary(provisional_approval_result(approval_eligibility)), summary_run_id, - previous_bot_comment_refs.managed_approval_receipt, ), draft_note_ids, ) diff --git a/tests/test_ocr_compat.py b/tests/test_ocr_compat.py index 1956d42..2e1e522 100644 --- a/tests/test_ocr_compat.py +++ b/tests/test_ocr_compat.py @@ -888,6 +888,28 @@ def test_prepare_update_requires_human_review_for_minor_transition() -> None: ) +@pytest.mark.parametrize("version", ["1.10.0", "2.0.0"]) +def test_prepare_update_rejects_schema_one_minor_or_major_transition(version: str) -> None: + """Legacy evidence cannot prove a chain across a semantic-version boundary.""" + + module = load_script() + evidence = { + "schema_version": 1, + "version": version, + "result": "compatible", + "classification": "human-review-required", + } + + with pytest.raises(module.CompatibilityError, match="chain-aware evidence schema 2"): + module.prepare_update( + manifest_path=MANIFEST, + evidence=evidence, + fragment_number=73, + human_conclusions={version: "Synthetic reviewed conclusion."}, + root=PROJECT_ROOT, + ) + + def test_prepare_update_rejects_nonadjacent_minor_transition() -> None: module = load_script() evidence = { diff --git a/tests/test_operations_docs.py b/tests/test_operations_docs.py index 259d3b8..bc28756 100644 --- a/tests/test_operations_docs.py +++ b/tests/test_operations_docs.py @@ -65,24 +65,27 @@ def test_auto_approval_contract_is_default_on_exact_sha_and_own_user_only() -> N operations = OPERATIONS.read_text(encoding="utf-8") configuration = CONFIGURATION.read_text(encoding="utf-8") example = GITLAB_EXAMPLE.read_text(encoding="utf-8") + normalized_operations = " ".join(operations.split()) + normalized_configuration = " ".join(configuration.split()) for phrase in ( "`OCR_AUTO_APPROVE=true` is the default", "at most three findings", "severity exactly `low`", - "category exactly `style`, `documentation`, or\n`maintainability`", + "category exactly `style`, `documentation`, or `maintainability`", "`patch_id_sha`", "never retried against the new commit", - "never\ncalls `reset_approvals`", - "Partial, skipped, legacy,\nand disabled runs preserve", + "never removes an existing approval", + "Ineligible, partial, skipped, legacy, and disabled runs do not make an approval write", ): - assert phrase in operations + assert phrase in normalized_operations assert "`OCR_AUTO_APPROVE` defaults to `true`" in configuration assert "`false`,\n`0`, `no`, or `off`" in configuration assert ( "There are intentionally no\nenvironment variables for policy thresholds" in configuration ) + assert "never removes an existing approval" in normalized_configuration assert 'OCR_AUTO_APPROVE: "true"' in example diff --git a/tests/test_posting_approval.py b/tests/test_posting_approval.py index aee8f69..3bb3b5f 100644 --- a/tests/test_posting_approval.py +++ b/tests/test_posting_approval.py @@ -14,7 +14,6 @@ gitlab_approval, markers, settings, - snapshot, workflow, ) from ocr_toolkit.posting.payloads import build_marked_note_body @@ -109,7 +108,7 @@ def test_blocking_or_malformed_metadata_is_ineligible(self) -> None: for severity in ("critical", "high", "medium", "LOW", None, 1, True): with self.subTest(severity=severity): self.assertFalse(eligibility([finding(severity=severity)]).eligible) - for category in ("unknown", "STYLE", None, 1, True): + for category in ("unknown", "STYLE", None, 1, True, [], {}): with self.subTest(category=category): self.assertFalse(eligibility([finding(category=category)]).eligible) @@ -129,17 +128,13 @@ def test_incomplete_warning_omitted_budget_waived_and_legacy_block(self) -> None with self.subTest(name=name): self.assertFalse(decision.eligible) - self.assertFalse(eligibility(outcome=partial).may_unapprove) - self.assertTrue(eligibility(warnings=["synthetic warning"]).may_unapprove) - - def test_disabled_and_invalid_setting_never_allow_unapproval(self) -> None: + def test_disabled_and_invalid_setting_remain_non_actionable(self) -> None: for setting in ( settings.BooleanSetting(False), settings.BooleanSetting(False, valid=False), ): decision = eligibility(setting=setting) self.assertEqual(decision.result.status, approval.ApprovalStatus.DISABLED) - self.assertFalse(decision.may_unapprove) def test_eligible_provisional_summary_fails_closed_until_readback(self) -> None: provisional = approval.provisional_approval_result(eligibility()) @@ -154,72 +149,6 @@ def test_eligible_provisional_summary_fails_closed_until_readback(self) -> None: ) -class ApprovalReceiptTests(unittest.TestCase): - """Require versioned same-user ownership proof before unapproval.""" - - def test_receipt_round_trip_and_malformed_rejection(self) -> None: - receipt = markers.ManagedApprovalReceipt(7, "a" * 40) - body = build_marked_note_body( - markers.build_summary_run_marker("b" * 32) - + "\n" - + markers.build_managed_approval_receipt(receipt) - + "\n## Open Code Review\n" - ) - - self.assertEqual(markers.managed_approval_receipt_from_body(body), receipt) - self.assertIsNone( - markers.managed_approval_receipt_from_body( - body + "\n" + markers.build_managed_approval_receipt(receipt) - ) - ) - self.assertIsNone( - markers.managed_approval_receipt_from_body( - "" - ) - ) - - def test_receipt_embedded_in_model_controlled_fallback_is_rejected(self) -> None: - receipt = markers.ManagedApprovalReceipt(7, "a" * 40) - forged = build_marked_note_body( - "**Open Code Review fallback comments**\n\n" - + markers.build_summary_run_marker("b" * 32) - + "\n" - + markers.build_managed_approval_receipt(receipt) - + "\n## Open Code Review\n" - ) - - self.assertIsNone(markers.managed_approval_receipt_from_body(forged)) - - def test_snapshot_keeps_owned_receipt_when_notes_and_discussions_overlap(self) -> None: - receipt = markers.ManagedApprovalReceipt(7, "a" * 40) - body = build_marked_note_body( - markers.build_summary_run_marker("b" * 32) - + "\n" - + markers.build_managed_approval_receipt(receipt) - + "\n## Open Code Review\nsummary" - ) - - def paginate(_config: Any, endpoint: str, **_kwargs: Any) -> list[Any]: - if endpoint.startswith("/notes"): - return [{"id": 10, "author": {"id": 7}, "body": body}] - if endpoint == "/discussions": - return [ - {"id": "discussion", "notes": [{"id": 10, "author": {"id": 7}, "body": body}]} - ] - raise AssertionError(endpoint) - - with ( - patched_env(OCR_POST_MODE="direct"), - patched_attr(snapshot, "api_get_paginated", paginate), - ): - settings.post_mode.cache_clear() - refs = snapshot.collect_previous_bot_comment_refs(gitlab_config()) - settings.post_mode.cache_clear() - - self.assertIsNotNone(refs) - self.assertEqual(refs and refs.managed_approval_receipt, receipt) - - class GitLabApprovalAdapterTests(unittest.TestCase): """Exercise synchronization, exact-SHA writes, and bounded readback.""" @@ -326,12 +255,11 @@ def approve(_config: Any, sha: str) -> gitlab.GitLabWriteResult: patched_attr(gitlab, "approve_merge_request", approve), ): result = gitlab_approval.execute_approval( - gitlab_config(), eligibility(), self.SHA, None, sleep=lambda _seconds: None + gitlab_config(), eligibility(), self.SHA, sleep=lambda _seconds: None ) self.assertEqual(approve_shas, [self.SHA]) self.assertEqual(result.result.status, approval.ApprovalStatus.APPROVED) - self.assertEqual(result.receipt, markers.ManagedApprovalReceipt(7, self.SHA)) def test_ambiguous_approve_is_not_retried(self) -> None: writes: list[str] = [] @@ -354,14 +282,14 @@ def approve(_config: Any, sha: str) -> gitlab.GitLabWriteResult: patched_attr(gitlab, "approve_merge_request", approve), ): result = gitlab_approval.execute_approval( - gitlab_config(), eligibility(), self.SHA, None, sleep=lambda _seconds: None + gitlab_config(), eligibility(), self.SHA, sleep=lambda _seconds: None ) self.assertEqual(writes, [self.SHA]) self.assertEqual(result.result.status, approval.ApprovalStatus.FAILED) - def test_existing_managed_approval_for_other_sha_is_not_claimed_as_exact(self) -> None: - receipt = markers.ManagedApprovalReceipt(7, "c" * 40) + def test_existing_approval_is_preserved_without_write(self) -> None: + writes: list[str] = [] with ( patched_attr(gitlab, "api_request", self.api_sequence(own_approved=True)), patched_attr( @@ -373,15 +301,18 @@ def test_existing_managed_approval_for_other_sha_is_not_claimed_as_exact(self) - "patch_id_sha": "b" * 40, }, ), + patched_attr( + gitlab, + "approve_merge_request", + lambda *_args: writes.append("approve"), + ), ): - result = gitlab_approval.execute_approval( - gitlab_config(), eligibility(), self.SHA, receipt - ) + result = gitlab_approval.execute_approval(gitlab_config(), eligibility(), self.SHA) self.assertEqual(result.result.status, approval.ApprovalStatus.SKIPPED) - self.assertEqual(result.receipt, receipt) + self.assertEqual(writes, []) - def test_provider_writes_use_only_own_user_approval_endpoints(self) -> None: + def test_provider_write_uses_only_exact_sha_approval_endpoint(self) -> None: calls: list[tuple[str, dict[str, Any], str]] = [] def write( @@ -399,66 +330,19 @@ def write( with patched_attr(gitlab, "api_write_url_detailed", write): gitlab.approve_merge_request(gitlab_config(), self.SHA) - gitlab.unapprove_merge_request(gitlab_config()) self.assertEqual(calls[0][0].rsplit("/", 1)[-1], "approve") self.assertEqual(calls[0][1], {"sha": self.SHA}) - self.assertEqual(calls[1][0].rsplit("/", 1)[-1], "unapprove") - self.assertEqual(calls[1][1], {}) self.assertNotIn("reset_approvals", repr(calls)) - def test_unapproves_only_own_managed_approval_after_authoritative_result(self) -> None: - receipt = markers.ManagedApprovalReceipt(7, "c" * 40) - writes: list[str] = [] - approval_reads = 0 - - def request(*args: Any, **kwargs: Any) -> Any: - nonlocal approval_reads - own = approval_reads == 0 - if args[1] == "/approvals": - approval_reads += 1 - return self.api_sequence(own_approved=own)(*args, **kwargs) - - def unapprove(_config: Any) -> gitlab.GitLabWriteResult: - writes.append("unapprove") - return gitlab.GitLabWriteResult("posted") - - with ( - patched_attr(gitlab, "api_request", request), - patched_attr( - gitlab_approval, - "_latest_diff_version", - lambda _config: { - "id": 2, - "head_commit_sha": self.SHA, - "patch_id_sha": "b" * 40, - }, - ), - patched_attr(gitlab, "unapprove_merge_request", unapprove), - ): - result = gitlab_approval.execute_approval( - gitlab_config(), - eligibility([finding(category="security")]), - self.SHA, - receipt, - sleep=lambda _seconds: None, - ) - - self.assertEqual(writes, ["unapprove"]) - self.assertIsNone(result.receipt) - self.assertEqual(result.result.status, approval.ApprovalStatus.NOT_ELIGIBLE) - - def test_disabled_or_partial_review_preserves_receipt_without_api(self) -> None: - receipt = markers.ManagedApprovalReceipt(7, "c" * 40) + def test_disabled_or_partial_review_performs_no_approval_api(self) -> None: for decision in ( eligibility(setting=settings.BooleanSetting(False)), eligibility(outcome=ReviewOutcome("partial", "partial", False, True)), ): with self.subTest(status=decision.result.status): - result = gitlab_approval.execute_approval( - gitlab_config(), decision, self.SHA, receipt - ) - self.assertEqual(result.receipt, receipt) + result = gitlab_approval.execute_approval(gitlab_config(), decision, self.SHA) + self.assertEqual(result.result, decision.result) def test_latest_diff_version_uses_highest_valid_id_not_response_order(self) -> None: versions = [ @@ -499,14 +383,11 @@ def test_current_summary_readback_requires_one_owned_run_marker(self) -> None: def test_finalize_orders_publish_approval_summary_update_and_cleanup(self) -> None: calls: list[str] = [] - receipt = markers.ManagedApprovalReceipt(7, self.SHA) approved = gitlab_approval.ApprovalExecution( approval.ApprovalResult( approval.ApprovalStatus.APPROVED, "GitLab confirmed the toolkit user's exact-SHA approval", - managed=True, ), - receipt, ) def publish(*_args: Any) -> bool: @@ -544,9 +425,8 @@ def cleanup(*_args: Any) -> None: self.assertEqual(exit_code, 0) self.assertEqual(calls, ["publish", "approve", "summary", "cleanup"]) - def test_disabled_approval_preserves_receipt_without_summary_rewrite(self) -> None: + def test_disabled_approval_does_not_rewrite_unchanged_summary(self) -> None: calls: list[str] = [] - receipt = markers.ManagedApprovalReceipt(7, "c" * 40) decision = eligibility(setting=settings.BooleanSetting(False)) def update_summary(*_args: Any) -> bool: @@ -558,16 +438,14 @@ def update_summary(*_args: Any) -> bool: patched_attr( workflow, "execute_approval", - lambda *_args, **_kwargs: gitlab_approval.ApprovalExecution( - decision.result, receipt - ), + lambda *_args, **_kwargs: gitlab_approval.ApprovalExecution(decision.result), ), patched_attr(workflow, "replace_current_summary", update_summary), patched_attr(workflow, "finalize_previous_review_state", lambda *_args: None), ): exit_code = workflow.finalize_review_approval( gitlab_config(), - BotCommentRefs(managed_approval_receipt=receipt), + BotCommentRefs(), complete_outcome(), [], decision, diff --git a/tests/test_posting_suggestions.py b/tests/test_posting_suggestions.py index 16517e9..6084b0d 100644 --- a/tests/test_posting_suggestions.py +++ b/tests/test_posting_suggestions.py @@ -97,9 +97,20 @@ def test_non_omission_ellipsis_remains_valid_code(self) -> None: self.assertEqual(decision.state, suggestions.SuggestionState.ACTIONABLE) def test_diff_prefixed_replacement_is_omitted(self) -> None: - decision = evaluate(suggestion_code="+route:\n+ destination: 198.51.100.0/24") + replacements = ( + "+route:\n+ destination: 198.51.100.0/24", + "diff --git a/config/service.yml b/config/service.yml\n" + "index 1234567..89abcde 100644\n" + "--- a/config/service.yml\n" + "+++ b/config/service.yml\n" + "@@ -2,2 +2,2 @@\n" + "-route:\n+endpoint:", + ) - self.assertEqual(decision.omission, suggestions.SuggestionOmission.DIFF_PREFIXED) + for replacement in replacements: + with self.subTest(replacement=replacement): + decision = evaluate(suggestion_code=replacement) + self.assertEqual(decision.omission, suggestions.SuggestionOmission.DIFF_PREFIXED) def test_exact_noop_is_suppressed_without_existing_code(self) -> None: decision = evaluate( diff --git a/tests/test_release_authorization.py b/tests/test_release_authorization.py index 302f9b9..16eb21d 100644 --- a/tests/test_release_authorization.py +++ b/tests/test_release_authorization.py @@ -5,6 +5,9 @@ import base64 import importlib.util import json +import os +import subprocess +import sys from copy import deepcopy from pathlib import Path from types import ModuleType @@ -126,6 +129,7 @@ def authorize( requested_version: str = "", requested_commit: str = "", requested_head: str = "", + requested_base: str = "", ) -> dict[str, str]: """Call authorization with fully valid synthetic GitHub evidence by default.""" @@ -140,6 +144,7 @@ def authorize( requested_version, requested_commit, requested_head, + requested_base, ) @@ -148,6 +153,7 @@ def test_authorizes_exact_same_repository_release_merge() -> None: requested_version="0.1.0", requested_commit="a" * 40, requested_head="c" * 40, + requested_base="b" * 40, ) assert outputs == { @@ -194,6 +200,8 @@ def test_recovery_inputs_must_match_the_merged_pr() -> None: authorize(requested_commit="b" * 40) with pytest.raises(release.AuthorizationError, match="requested head"): authorize(requested_head="b" * 40) + with pytest.raises(release.AuthorizationError, match="requested base"): + authorize(requested_base="c" * 40) def test_rejects_tree_parent_and_commit_identity_mismatches() -> None: @@ -325,7 +333,8 @@ def test_release_workflow_keeps_strict_release_authorization() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") assert "python scripts/release_authorization.py" in workflow - assert "github.event.pull_request.merge_commit_sha || inputs['merge-commit']" in workflow + assert "github.event.pull_request.base.sha || inputs['reviewed-base']" in workflow + assert "Candidate head and merge commits are inspected only as data" in workflow assert 'test "${RELEASE_BRANCH}" = "release/v${VERSION}"' in workflow assert "repos/${REPOSITORY}/rules/branches/main" in workflow assert "commits/${HEAD_SHA}/check-runs?filter=latest&per_page=100" in workflow @@ -342,8 +351,68 @@ def test_release_workflow_keeps_strict_release_authorization() -> None: assert "contents/.release-metadata.json?ref=${MERGE_SHA}" in workflow assert "--release-metadata-contents-json /tmp/release-metadata-contents.json" in workflow assert "--requested-head" in workflow + assert "--requested-base" in workflow assert "Validate tracked release issues before publication" in workflow assert "--validate-issue-only" in workflow assert 'test "$(git rev-parse HEAD^{tree})" = "${EXPECTED_TREE}"' in workflow assert 'test "$(git rev-parse FETCH_HEAD^{tree})" = "${EXPECTED_TREE}"' in workflow assert "github.event.pull_request.merged == true" not in workflow + + +def test_bounded_github_helper_rejects_unknown_endpoints_and_writes_atomically( + tmp_path: Path, +) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + curl = fake_bin / "curl" + curl.write_text( + "#!/bin/sh\n" + "output=\n" + "previous=\n" + 'for argument in "$@"; do\n' + ' if [ "${previous}" = --output ]; then output=${argument}; fi\n' + " previous=${argument}\n" + "done\n" + "printf 'partial' > \"${output}\"\n" + "printf '500'\n" + "exit 0\n", + encoding="utf-8", + ) + curl.chmod(0o700) + output = tmp_path / "response.json" + output.write_text("original", encoding="utf-8") + environment = dict(os.environ) + environment.update( + { + "PATH": f"{fake_bin}:{Path(sys.executable).parent}:/usr/bin:/bin", + "GH_TOKEN": "synthetic-token", + } + ) + + rejected = subprocess.run( + [str(BOUNDED_API), "repos/synthetic/project/unknown", str(output)], + check=False, + capture_output=True, + text=True, + env=environment, + ) + assert rejected.returncode == 1 + assert output.read_text(encoding="utf-8") == "original" + + failed = subprocess.run( + [str(BOUNDED_API), "repos/synthetic/project/issues/7", str(output)], + check=False, + capture_output=True, + text=True, + env=environment, + ) + assert failed.returncode == 1 + assert output.read_text(encoding="utf-8") == "original" + assert not list(tmp_path.glob(".bounded-github-api.*")) + + +def test_bounded_github_helper_uses_redirect_safe_bearer_auth(tmp_path: Path) -> None: + helper = BOUNDED_API.read_text(encoding="utf-8") + assert '--oauth2-bearer "${GH_TOKEN:?GH_TOKEN is required}"' in helper + assert 'header "Authorization:' not in helper + assert 'mktemp "${output_directory}/.bounded-github-api.XXXXXX"' in helper diff --git a/tests/test_release_receipt.py b/tests/test_release_receipt.py index ce8b9a2..4389f48 100644 --- a/tests/test_release_receipt.py +++ b/tests/test_release_receipt.py @@ -148,6 +148,34 @@ def test_existing_receipt_recovery_ignores_new_run_but_rejects_delivery_drift() ) +def test_existing_receipt_rejects_unknown_top_level_and_workflow_fields() -> None: + common = { + "version": "0.4.7", + "tag": "v0.4.7", + "release_pr": 73, + "issues": [70, 71], + "base": "a" * 40, + "head": "b" * 40, + "merge": "c" * 40, + "tree": "d" * 40, + "authorized_at": "2026-08-10T10:00:00Z", + "artifacts": { + "open_code_review_toolkit-0.4.7.tar.gz": "e" * 64, + "open_code_review_toolkit-0.4.7-py3-none-any.whl": "f" * 64, + }, + "python_minors": ["3.12", "3.13", "3.14"], + } + payload = build_receipt() + payload["future_claim"] = "verified" + with pytest.raises(receipt.ReceiptError, match="schema shape"): + receipt.validate_receipt(payload, **common) + + payload = build_receipt() + payload["workflow"]["future_identity"] = 1 + with pytest.raises(receipt.ReceiptError, match="workflow identity"): + receipt.validate_receipt(payload, **common) + + def encoded_statement(filename: str, digest: str) -> str: """Return one base64 PyPI publish-attestation statement.""" diff --git a/tests/test_testpypi_preview.py b/tests/test_testpypi_preview.py index 8ebb15a..73eac6c 100644 --- a/tests/test_testpypi_preview.py +++ b/tests/test_testpypi_preview.py @@ -159,6 +159,11 @@ def test_artifact_manifest_accepts_only_complete_trusted_release() -> None: with pytest.raises(preview.PreviewError, match="invalid registry URL"): preview.artifact_manifest(malformed_provenance, "0.1.0a3") + malformed_host = payload(hashes) + malformed_host["files"][0]["provenance"] = "https://[invalid/provenance" + with pytest.raises(preview.PreviewError, match="invalid registry URL"): + preview.artifact_manifest(malformed_host, "0.1.0a3") + def test_workflow_automates_one_idempotent_development_build_per_main_run() -> None: workflow = WORKFLOW.read_text(encoding="utf-8")