Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agent-loop/CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ authority; these records do not grant or withhold it.
| [WS-POL-001](initiatives/WS-POL-001-submission-artifact-policy-foundation/STATUS.md) | Foundation initiative complete | Follow-up behavior belongs to current ART, POL, REV, or CON initiatives |
| [WS-QUAL-001](initiatives/WS-QUAL-001-backend-coverage-floor/STATUS.md) | Coverage closure complete; blocking mutation rollout retired | Preserve global 78 percent and protected-subsystem 90 percent floors; mutation needs a fresh changed-line-aware plan |
| [WS-CI-001](initiatives/WS-CI-001-backend-ci-acceleration/STATUS.md) | Semantic distributed backend lanes complete | Treat further CI optimization as a fresh measured bounded change |
| [WS-CI-002](initiatives/WS-CI-002-deterministic-agent-gates/STATUS.md) | Active bounded CI repair | Make Agent Gates deterministic per PR head while protected-branch review remains the approval authority |
| [WS-DOCS-001](initiatives/WS-DOCS-001-current-v01-documentation/STATUS.md) | Current v0.1 entry documentation complete | Keep current pages synchronized with merged capability changes |
| [WS-DOCS-002](initiatives/WS-DOCS-002-workstream-definition/STATUS.md) | Canonical Workstream definition complete | Preserve terminology across current documentation and generated artifacts |
| [WS-XINT-001](initiatives/WS-XINT-001-lifecycle-boundary-reconciliation/STATUS.md) | Planning reconciliation complete and closed | Owner initiatives implement the resulting boundaries |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# STATUS: WS-CI-002 — Deterministic Agent Gates

- Phase: ready for human review
- Completed chunk: `WS-CI-002-01`
- Goal: make the required `agent-gates` result deterministic for each PR head
while leaving independent human approval to protected-branch review rules.
- Trigger: PR #309 remained blocked by several pre-approval failures after its
exact head had passed every test and received an independent approval.
- Evidence: 31 dependency-gate tests and 11 lightweight workflow tests pass;
CI-integrity and test-delta review pass with no required fixes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Chunk Contract: WS-CI-002-01 — Single-Head Agent Gate

## Goal

Produce one deterministic required `agent-gates` result per PR head and use
branch protection, not a CI review-API poll, for independent approval.

## Why this chunk exists

The workflow currently runs on PR changes and review changes under the same
required context. Dependency-manifest PRs fail before approval and later pass
after approval, leaving contradictory same-head results that GitHub sometimes
retains as merge blockers.

## Risk class

L1 CI and contribution authority.

## Allowed files

```text
.github/workflows/agent-gates.yml
backend/scripts/check_guide_extractor_dependencies.py
backend/tests/test_guide_extractor_dependencies.py
scripts/test_lightweight_agent_gates.py
docs/operations_backend_testing.md
.agent-loop/CURRENT_STATE.md
.agent-loop/initiatives/WS-CI-002-deterministic-agent-gates/**
```

## Not allowed

- Removing static dependency-manifest validation.
- Weakening dependency hashes, platform constraints, import boundaries, tests,
lint, coverage, or any Backend lane.
- Reducing protected-branch approval requirements.
- Adding an approval bypass, fallback success, or `continue-on-error`.
- Changing product behavior or dependencies.

## Acceptance criteria

1. `agent-gates` runs on head-changing PR events, not review or body-edit events.
2. Superseded in-progress runs for the same PR are cancelled.
3. The dependency gate validates repository content without querying live PR reviews.
4. Obsolete review-fetching code and its tests are removed rather than retained.
5. Lightweight regression tests lock the deterministic trigger and static command.
6. Existing branch protection remains responsible for the required independent approval.
7. All affected tests and exact-head GitHub checks pass.

## Verification commands

```bash
python3 -m unittest -v scripts.test_lightweight_agent_gates
cd backend
.venv/bin/ruff check scripts/check_guide_extractor_dependencies.py tests/test_guide_extractor_dependencies.py
.venv/bin/pytest -q tests/test_guide_extractor_dependencies.py
cd ..
python3 scripts/check_markdown_links.py
python3 scripts/check_stale_workstream_wording.py
git diff --check origin/main...HEAD
```

## Required reviewers

CI integrity and test delta.

## Human review focus

Confirm that review authority still comes from protected-branch approval and
that `agent-gates` now represents only deterministic repository validation.

## Stop conditions

Stop if the change weakens branch protection, dependency validation, tests,
coverage, or requires a successful check without validating repository content.
22 changes: 8 additions & 14 deletions .github/workflows/agent-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ name: Agent Gates

on:
pull_request:
types: [opened, synchronize, reopened, edited]
pull_request_review:
types: [submitted, dismissed]
types: [opened, synchronize, reopened]

concurrency:
group: agent-gates-${{ github.event.pull_request.number }}
cancel-in-progress: true
Comment thread
coderabbitai[bot] marked this conversation as resolved.

permissions:
contents: read
pull-requests: read

jobs:
agent-gates:
Expand All @@ -17,6 +18,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
fetch-depth: 0

Expand All @@ -36,16 +38,8 @@ jobs:
- name: Stale artifact contract check
run: python3 scripts/check_stale_artifact_contracts.py

- name: Guide extractor dependency approval
env:
GITHUB_TOKEN: ${{ github.token }}
WORKSTREAM_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
WORKSTREAM_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
WORKSTREAM_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
WORKSTREAM_PR_NUMBER: ${{ github.event.pull_request.number }}
run: >-
python3 backend/scripts/check_guide_extractor_dependencies.py
--require-pr-approval
- name: Guide extractor dependency validation
run: python3 backend/scripts/check_guide_extractor_dependencies.py

- name: Lightweight gate regression tests
run: python3 -m unittest -v scripts.test_lightweight_agent_gates
126 changes: 2 additions & 124 deletions backend/scripts/check_guide_extractor_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,19 @@

from __future__ import annotations

import argparse
import ast
import hashlib
import json
import os
import platform
import re
import subprocess
import sys
import tomllib
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any

BACKEND_ROOT = Path(__file__).resolve().parents[1]
REPOSITORY_ROOT = BACKEND_ROOT.parent
ALLOWLIST_PATH = BACKEND_ROOT / "config" / "guide_extractor_dependencies.json"
PYPROJECT_PATH = BACKEND_ROOT / "pyproject.toml"
ALLOWLIST_REPOSITORY_PATH = ALLOWLIST_PATH.relative_to(REPOSITORY_ROOT).as_posix()
APPROVED_ASSOCIATIONS = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
APPROVED_SCOPES = frozenset({"pdf", "ooxml", "image_metadata"})
APPROVED_NATIVE_PYTHON_RANGE = ">=3.11,<3.13"
APPROVED_NATIVE_MACHINES = frozenset({"aarch64", "x86_64"})
Expand All @@ -32,7 +24,6 @@
{"libc": "glibc>=2.27", "machine": "x86_64", "system": "Linux"},
]
MINIMUM_GLIBC_VERSION = (2, 27)
STATE_BEARING_REVIEW_STATES = frozenset({"APPROVED", "CHANGES_REQUESTED", "DISMISSED"})
PARSER_MODULES = (
"guide_pdf.py",
"guide_ooxml.py",
Expand Down Expand Up @@ -481,128 +472,15 @@ def validate_parser_imports(allowlist: dict[str, dict[str, Any]]) -> None:
)


def allowlist_changed(base_sha: str, head_sha: str) -> bool:
"""Return whether the exact allowlist bytes differ across the PR range."""
require(bool(re.fullmatch(r"[0-9a-f]{40}", base_sha)), "guide_dependency_base_sha_invalid")
require(bool(re.fullmatch(r"[0-9a-f]{40}", head_sha)), "guide_dependency_head_sha_invalid")
result = subprocess.run(
["git", "diff", "--quiet", f"{base_sha}...{head_sha}", "--", ALLOWLIST_REPOSITORY_PATH],
cwd=REPOSITORY_ROOT,
check=False,
)
require(result.returncode in {0, 1}, "guide_dependency_git_diff_failed")
return result.returncode == 1


def validate_reviews(
reviews: list[dict[str, Any]], *, author: str, head_sha: str
) -> dict[str, Any]:
"""Return one current independent approval or fail closed."""
require(bool(author), "guide_dependency_pr_author_missing")
require(bool(re.fullmatch(r"[0-9a-f]{40}", head_sha)), "guide_dependency_head_sha_invalid")
latest_by_reviewer: dict[str, dict[str, Any]] = {}
for review in reviews:
require(isinstance(review, dict), "guide_dependency_review_shape_invalid")
user = review.get("user")
require(isinstance(user, dict), "guide_dependency_review_user_missing")
login = user.get("login")
require(isinstance(login, str) and login, "guide_dependency_review_login_missing")
if review.get("state") not in STATE_BEARING_REVIEW_STATES:
continue
latest_by_reviewer[login.casefold()] = review

candidates: list[dict[str, Any]] = []
for login, review in latest_by_reviewer.items():
user = review["user"]
if login == author.casefold():
continue
if user.get("type") != "User" or login.endswith("[bot]"):
continue
if review.get("author_association") not in APPROVED_ASSOCIATIONS:
continue
if review.get("state") != "APPROVED":
continue
if review.get("commit_id") != head_sha:
continue
candidates.append(review)
require(bool(candidates), "guide_dependency_independent_head_approval_missing")
return candidates[-1]


def fetch_reviews(repository: str, pull_number: int, token: str) -> list[dict[str, Any]]:
"""Read every PR review using the minimal read-only GitHub token."""
require(bool(re.fullmatch(r"[^/]+/[^/]+", repository)), "guide_dependency_repository_invalid")
require(pull_number > 0, "guide_dependency_pull_number_invalid")
require(bool(token), "guide_dependency_github_token_missing")
reviews: list[dict[str, Any]] = []
page = 1
while True:
request = urllib.request.Request(
f"https://api.github.com/repos/{repository}/pulls/{pull_number}/reviews?per_page=100&page={page}",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "workstream-guide-dependency-gate",
},
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
payload = json.load(response)
except (OSError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
raise DependencyGateError("guide_dependency_github_reviews_unavailable") from exc
require(isinstance(payload, list), "guide_dependency_github_reviews_invalid")
reviews.extend(payload)
if len(payload) < 100:
return reviews
page += 1


def validate_pr_approval(raw_allowlist: bytes) -> None:
"""Validate live review authority when this PR changes the allowlist."""
base_sha = os.environ.get("WORKSTREAM_PR_BASE_SHA", "")
head_sha = os.environ.get("WORKSTREAM_PR_HEAD_SHA", "")
if not allowlist_changed(base_sha, head_sha):
return
repository = os.environ.get("GITHUB_REPOSITORY", "")
author = os.environ.get("WORKSTREAM_PR_AUTHOR", "")
pull_number_value = os.environ.get("WORKSTREAM_PR_NUMBER", "")
require(pull_number_value.isdigit(), "guide_dependency_pull_number_invalid")
reviews = fetch_reviews(
repository,
int(pull_number_value),
os.environ.get("GITHUB_TOKEN", ""),
)
approval = validate_reviews(reviews, author=author, head_sha=head_sha)
digest = hashlib.sha256(raw_allowlist).hexdigest()
print(
"Guide dependency approval: "
f"review={approval.get('id')} head={head_sha} allowlist_sha256={digest}"
)


def parse_args() -> argparse.Namespace:
"""Parse the small CI-facing command surface."""
parser = argparse.ArgumentParser()
parser.add_argument(
"--require-pr-approval",
action="store_true",
help="Require live exact-head review authority when the PR changes the allowlist.",
)
return parser.parse_args()


def main() -> int:
"""Run static validation and the optional live PR approval gate."""
args = parse_args()
"""Run deterministic guide-extractor dependency validation."""
try:
require(not sys.argv[1:], "guide_dependency_arguments_unsupported")
data, raw = load_manifest()
allowlist = validate_manifest(data)
validate_declared_dependencies(allowlist, data["prohibited_packages"])
validate_runtime_platform()
validate_parser_imports(allowlist)
if args.require_pr_approval:
validate_pr_approval(raw)
except DependencyGateError as exc:
print(str(exc), file=sys.stderr)
return 1
Expand Down
Loading
Loading