diff --git a/.github/actions/noema-review/two_phase.py b/.github/actions/noema-review/two_phase.py new file mode 100644 index 0000000000..1cab5aa411 --- /dev/null +++ b/.github/actions/noema-review/two_phase.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Prepare and publish Noema verdicts across short-lived reviewer credentials. + +The model phase can legitimately outlive a one-hour GitHub App installation +credential. This trusted helper therefore seals the already validated model +verdict to a runner-local file, then a later workflow step reopens that file +only after the reviewer credential has been refreshed. Publication always +re-fetches the live pull request and verifies its exact head and base before +submitting any review evidence. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[3] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.ci import noema_review_gate as gate # noqa: E402 + +ENVELOPE_SCHEMA_VERSION = 1 +MAX_ENVELOPE_BYTES = 2 * 1024 * 1024 + + +def _canonical_head(value: str) -> str: + """Return one canonical lowercase Git SHA or fail closed.""" + head = value.strip().lower() + if not re.fullmatch(r"[0-9a-f]{40}", head): + raise RuntimeError("Noema two-phase handoff requires a canonical 40-character Git SHA") + return head + + +def _canonical_base(pull_request: dict[str, Any]) -> str: + """Return the exact base commit that defined the reviewed diff/context.""" + base = str(pull_request.get("baseRefOid") or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]{40}", base): + raise RuntimeError("Noema two-phase handoff requires a canonical 40-character base SHA") + return base + + +def _reviewer_actor() -> str: + """Return a verified independent reviewer actor for the active token.""" + actor = gate.current_actor() + if not actor: + raise RuntimeError("Noema reviewer identity could not be verified") + if actor in gate.PRIMARY_REVIEW_AUTHORS: + raise RuntimeError( + f"Current token actor {actor!r} is already a primary review actor; " + "Noema requires an independent reviewer credential." + ) + return actor + + +def _write_envelope(path: Path, payload: dict[str, Any]) -> None: + """Create one private, non-following runner-local verdict envelope.""" + encoded = (json.dumps(payload, separators=(",", ":"), sort_keys=True) + "\n").encode("utf-8") + if len(encoded) > MAX_ENVELOPE_BYTES: + raise RuntimeError("Noema verdict envelope exceeds the bounded handoff size") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags, 0o600) + try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_nlink != 1: + raise RuntimeError("Noema verdict envelope target is not a private regular file") + view = memoryview(encoded) + written = 0 + while written < len(view): + count = os.write(fd, view[written:]) + if count <= 0: + raise RuntimeError("Noema verdict envelope write made no forward progress") + written += count + os.fsync(fd) + except BaseException: + os.close(fd) + path.unlink(missing_ok=True) + raise + else: + os.close(fd) + + +def _read_envelope(path: Path) -> dict[str, Any]: + """Read and validate one sealed runner-local verdict envelope.""" + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(path, flags) + except OSError as exc: + raise RuntimeError("Noema verdict envelope is unavailable for publication") from exc + try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_nlink != 1: + raise RuntimeError("Noema verdict envelope is not a regular single-link file") + if file_stat.st_mode & 0o077: + raise RuntimeError("Noema verdict envelope permissions are broader than owner-only") + if file_stat.st_size <= 0 or file_stat.st_size > MAX_ENVELOPE_BYTES: + raise RuntimeError("Noema verdict envelope size is outside the bounded contract") + chunks: list[bytes] = [] + remaining = MAX_ENVELOPE_BYTES + 1 + while remaining > 0: + chunk = os.read(fd, min(65536, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + raw = b"".join(chunks) + if len(raw) > MAX_ENVELOPE_BYTES: + raise RuntimeError("Noema verdict envelope exceeded the bounded read limit") + finally: + os.close(fd) + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Noema verdict envelope is malformed") from exc + if not isinstance(payload, dict): + raise RuntimeError("Noema verdict envelope root must be an object") + return payload + + +def prepare_verdict(repo: str, number: int, expected_head: str, path: Path) -> int: + """Run model review and seal its verdict without publishing GitHub evidence.""" + expected = _canonical_head(expected_head) + pull_request = gate.fetch_pr(repo, number) + try: + gate.require_expected_head(pull_request, expected) + except RuntimeError: + print("Pull request is closed or stale; Noema verdict preparation skipped.") + return 0 + expected_base = _canonical_base(pull_request) + actor = _reviewer_actor() + if pull_request.get("isDraft"): + print("PR is draft; Noema verdict preparation skipped.") + return 0 + if gate.existing_noema_review(pull_request, actor): + print("Current head already has a Noema review; verdict preparation skipped.") + return 0 + + diff, truncated = gate.fetch_diff(repo, number) + changed_files = gate.fetch_changed_files(repo, number) + changed_paths = tuple(file_path for file_path, _status in changed_files) + review_context = gate.build_review_context(repo, number, pull_request, changed_files) + try: + verdict = gate.call_llm( + repo, + number, + pull_request, + diff, + truncated, + expected, + review_context, + changed_paths, + ) + except gate.StaleHeadDuringRepairRetryError: + print("Pull request head changed during model repair retry; verdict was not sealed.") + return 0 + + _write_envelope( + path, + { + "schema_version": ENVELOPE_SCHEMA_VERSION, + "repository": repo, + "pull_request_number": number, + "expected_head": expected, + "expected_base": expected_base, + "verdict": verdict, + }, + ) + print( + f"Prepared Noema verdict for {repo}#{number} at head {expected} / base {expected_base}; " + "publication is deferred." + ) + return 0 + + +def publish_verdict(repo: str, number: int, expected_head: str, path: Path) -> int: + """Publish a prepared verdict only with fresh exact-head/base reviewer authority.""" + expected = _canonical_head(expected_head) + try: + payload = _read_envelope(path) + required_keys = { + "schema_version", + "repository", + "pull_request_number", + "expected_head", + "expected_base", + "verdict", + } + if set(payload) != required_keys: + raise RuntimeError("Noema verdict envelope fields do not match the trusted schema") + if payload["schema_version"] != ENVELOPE_SCHEMA_VERSION: + raise RuntimeError("Noema verdict envelope schema version is unsupported") + if payload["repository"] != repo or payload["pull_request_number"] != number: + raise RuntimeError("Noema verdict envelope target identity does not match publication") + if payload["expected_head"] != expected: + raise RuntimeError("Noema verdict envelope head does not match publication") + expected_base = str(payload["expected_base"]).strip().lower() + if not re.fullmatch(r"[0-9a-f]{40}", expected_base): + raise RuntimeError("Noema verdict envelope base does not contain a canonical Git SHA") + verdict = payload["verdict"] + if not isinstance(verdict, dict): + raise RuntimeError("Noema verdict envelope verdict must be an object") + + current_pull_request = gate.fetch_pr(repo, number) + try: + gate.require_expected_head(current_pull_request, expected) + except RuntimeError: + print("Pull request closed or advanced after model review; prepared verdict was not published.") + return 0 + if _canonical_base(current_pull_request) != expected_base: + print("Pull request base advanced after model review; stale prepared verdict was not published.") + return 0 + actor = _reviewer_actor() + if current_pull_request.get("isDraft"): + print("PR became draft after model review; prepared verdict was not published.") + return 0 + if gate.existing_noema_review(current_pull_request, actor): + print("Current head already has a Noema review; duplicate publication skipped.") + return 0 + gate.submit_review(repo, number, current_pull_request, actor, verdict) + return 0 + finally: + path.unlink(missing_ok=True) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse the trusted two-phase handoff command line.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", required=True) + parser.add_argument("--pr-number", required=True, type=int) + parser.add_argument("--expected-head", required=True) + modes = parser.add_mutually_exclusive_group(required=True) + modes.add_argument("--prepare-verdict-file", type=Path) + modes.add_argument("--publish-verdict-file", type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + """Execute the selected prepare or publication phase.""" + args = parse_args(argv) + if args.pr_number <= 0: + raise SystemExit("--pr-number must be positive") + if args.prepare_verdict_file is not None: + return prepare_verdict(args.repo, args.pr_number, args.expected_head, args.prepare_verdict_file) + return publish_verdict(args.repo, args.pr_number, args.expected_head, args.publish_verdict_file) + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except RuntimeError as exc: + print(f"::error::{exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 794c94569f..6b2e3fcede 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -552,8 +552,9 @@ jobs: set -euo pipefail bash "$GITHUB_WORKSPACE/scripts/ci/contextual_orchestrator_review_sidecar.sh" - - name: Run Noema LLM review and submit verdict + - name: Prepare Noema model verdict if: env.PR_NUMBER != '' + id: noema_prepare env: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source == 'pat' && 'noema-review-pat' || steps.noema_credential.outputs.source == 'github-app' && 'noema-review-github-app' || 'noema-review-app-oidc' }} @@ -563,10 +564,11 @@ jobs: set -euo pipefail if [ -z "${PR_NUMBER:-}" ]; then echo "No pull request number was available for this event; skipping." + echo "prepared=false" >>"$GITHUB_OUTPUT" exit 0 fi if [ -z "${GH_TOKEN:-}" ]; then - echo "::error::Noema reviewer credential selection succeeded but no token was minted; review cannot submit a verdict." + echo "::error::Noema reviewer credential selection succeeded but no token was minted; review cannot prepare a verdict." exit 1 fi if [ -z "${CONTEXTUAL_ORCHESTRATOR_BASE_URL:-}" ] || [ -z "${CONTEXTUAL_ORCHESTRATOR_TOKEN_FILE:-}" ]; then @@ -578,7 +580,50 @@ jobs: export NOEMA_LLM_MODEL="orchestrator/free" export NOEMA_LLM_API_KEY="${CONTEXTUAL_ORCHESTRATOR_TOKEN}" export NOEMA_LLM_VIA_ORCHESTRATOR=1 - python3 -m scripts.ci.noema_review_gate \ - --repo "$TARGET_REPOSITORY" \ - --pr-number "$PR_NUMBER" \ - --expected-head "$EXPECTED_HEAD_SHA" + verdict_file="${RUNNER_TEMP}/noema-verdict-envelope.json" + rm -f "$verdict_file" + python3 "$GITHUB_WORKSPACE/.github/actions/noema-review/two_phase.py" --repo "$TARGET_REPOSITORY" --pr-number "$PR_NUMBER" --expected-head "$EXPECTED_HEAD_SHA" --prepare-verdict-file "$verdict_file" + if [ -f "$verdict_file" ]; then + echo "prepared=true" >>"$GITHUB_OUTPUT" + else + echo "prepared=false" >>"$GITHUB_OUTPUT" + echo "::notice::Noema model phase produced no publishable envelope; publication is skipped." + fi + + - name: Refresh repository-scoped Noema GitHub App token for publication + if: env.PR_NUMBER != '' && steps.noema_prepare.outputs.prepared == 'true' && steps.noema_credential.outputs.source == 'github-app' + id: noema_github_app_publication_token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.NOEMA_GITHUB_APP_CLIENT_ID }} + private-key: ${{ secrets.NOEMA_GITHUB_APP_PRIVATE_KEY }} + owner: ContextualWisdomLab + repositories: ${{ steps.noema_credential.outputs.repository }} + permission-actions: read + permission-checks: read + permission-contents: read + permission-metadata: read + permission-pull-requests: write + permission-security-events: read + permission-statuses: read + permission-vulnerability-alerts: read + + - name: Publish prepared Noema verdict on the exact live head + if: env.PR_NUMBER != '' && steps.noema_prepare.outputs.prepared == 'true' + env: + GH_TOKEN: ${{ steps.noema_credential.outputs.source == 'pat' && secrets.NOEMA_REVIEW_TOKEN || steps.noema_credential.outputs.source == 'github-app' && steps.noema_github_app_publication_token.outputs.token || steps.noema_credential.outputs.source == 'oidc' && steps.noema_oidc_token.outputs.token || '' }} + NOEMA_REVIEW_TOKEN_SOURCE: ${{ steps.noema_credential.outputs.source == 'pat' && 'noema-review-pat' || steps.noema_credential.outputs.source == 'github-app' && 'noema-review-github-app-refresh' || steps.noema_credential.outputs.source == 'oidc' && 'noema-review-app-oidc' || '' }} + NOEMA_REVIEW_ACTOR: ${{ steps.noema_github_app_publication_token.outputs['app-slug'] && format('{0}[bot]', steps.noema_github_app_publication_token.outputs['app-slug']) || '' }} + NOEMA_REVIEW_INSTALLATION_ID: ${{ steps.noema_github_app_publication_token.outputs['installation-id'] }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::Noema publication has no credential for the explicitly selected reviewer source; refusing any GITHUB_TOKEN or author fallback." + exit 1 + fi + verdict_file="${RUNNER_TEMP}/noema-verdict-envelope.json" + if [ ! -f "$verdict_file" ]; then + echo "::error::Noema prepared-verdict output claimed success but its private envelope is missing." + exit 1 + fi + python3 "$GITHUB_WORKSPACE/.github/actions/noema-review/two_phase.py" --repo "$TARGET_REPOSITORY" --pr-number "$PR_NUMBER" --expected-head "$EXPECTED_HEAD_SHA" --publish-verdict-file "$verdict_file" diff --git a/.github/workflows/noema-token-lifetime-quality-ci.yml b/.github/workflows/noema-token-lifetime-quality-ci.yml new file mode 100644 index 0000000000..3de8f18ab3 --- /dev/null +++ b/.github/workflows/noema-token-lifetime-quality-ci.yml @@ -0,0 +1,36 @@ +name: Noema Reviewer Token Lifetime CI + +on: + pull_request: + paths: + - .github/workflows/noema-review.yml + - .github/actions/noema-review/two_phase.py + - tests/test_noema_reviewer_token_lifetime.py + - tests/test_noema_two_phase_handoff.py + - docs/doctoring/noema-review-token-lifetime.md + - docs/product-technical-gap-baseline.md + - CHANGELOG.md + - requirements-opencode-review-ci-hashes.txt + - .github/workflows/noema-token-lifetime-quality-ci.yml + +permissions: + contents: read + +jobs: + noema-reviewer-token-lifetime: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout exact source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install pinned review CI dependencies + run: >- + python3 -m pip install --disable-pip-version-check --require-hashes --only-binary=:all: -r requirements-opencode-review-ci-hashes.txt + - name: Verify token-lifetime handoff contracts + run: | + set -euo pipefail + PYTHONPATH=. python3 -m pytest -q tests/test_noema_reviewer_token_lifetime.py tests/test_noema_two_phase_handoff.py + python3 -m compileall -q .github/actions/noema-review/two_phase.py tests/test_noema_reviewer_token_lifetime.py tests/test_noema_two_phase_handoff.py + git diff --check diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index cc0b84dff1..bb5d439c3f 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -24,12 +24,6 @@ permissions: contents: read jobs: - required-workflow-bootstrap: - name: required-workflow-bootstrap - runs-on: ubuntu-latest - steps: - - run: echo "OpenCode repository-dispatch review run materialized." - validate-pr-metadata: name: validate-pr-metadata if: github.event_name == 'repository_dispatch' diff --git a/.github/workflows/repository-metadata-reconcile.yml b/.github/workflows/repository-metadata-reconcile.yml new file mode 100644 index 0000000000..90b3a1b7e8 --- /dev/null +++ b/.github/workflows/repository-metadata-reconcile.yml @@ -0,0 +1,181 @@ +name: Repository Metadata Reconcile + +on: + pull_request: + paths: + - "config/repository-metadata.json" + - "config/repository-label-taxonomy.json" + - "scripts/ci/reconcile_repository_metadata.py" + - "scripts/ci/reconcile_repository_labels.py" + - "tests/test_repository_metadata_reconciliation.py" + - "tests/test_repository_metadata_convergence.py" + - "tests/test_repository_metadata_identity.py" + - "tests/test_repository_metadata_live_verification.py" + - "tests/test_repository_label_taxonomy.py" + - "tests/test_repository_label_reconciliation.py" + - "tests/test_repository_label_convergence.py" + - "tests/test_repository_label_identity.py" + - "tests/test_repository_label_live_verification.py" + - ".github/workflows/repository-metadata-reconcile.yml" + schedule: + - cron: "23 * * * *" + +permissions: + contents: read + +concurrency: + group: repository-metadata-reconcile-${{ github.ref }} + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Check out exact revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Verify exact revision + shell: bash + run: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Install hash-locked test tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + --only-binary=:all: -r requirements-opencode-review-ci-hashes.txt + - name: Validate desired state + run: | + set -euo pipefail + python scripts/ci/reconcile_repository_metadata.py \ + --manifest config/repository-metadata.json \ + --validate-only + python scripts/ci/reconcile_repository_labels.py \ + --taxonomy config/repository-label-taxonomy.json \ + --validate-only + - name: Run metadata contract tests at repository quality gates + env: + COVERAGE_RCFILE: /dev/null + run: | + set -euo pipefail + python -m coverage run \ + --branch \ + --include=scripts/ci/reconcile_repository_metadata.py \ + -m pytest -q \ + tests/test_repository_metadata_reconciliation.py \ + tests/test_repository_metadata_identity.py \ + tests/test_repository_metadata_live_verification.py + python -m coverage report \ + --fail-under=100 \ + --show-missing \ + --include=scripts/ci/reconcile_repository_metadata.py + python -m coverage erase + python -m coverage run \ + --branch \ + --include=scripts/ci/reconcile_repository_labels.py \ + -m pytest -q \ + tests/test_repository_label_reconciliation.py \ + tests/test_repository_label_convergence.py \ + tests/test_repository_label_identity.py \ + tests/test_repository_label_live_verification.py + python -m coverage report \ + --fail-under=100 \ + --show-missing \ + --include=scripts/ci/reconcile_repository_labels.py + python -m interrogate \ + --fail-under 100 \ + scripts/ci/reconcile_repository_metadata.py \ + scripts/ci/reconcile_repository_labels.py + python -m pytest -q + git diff --check + + apply: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 45 + environment: repository-metadata-maintenance + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Check out trusted default branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Verify exact revision + shell: bash + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Reconcile and verify repository public surfaces + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + run: | + set +e + python scripts/ci/reconcile_repository_metadata.py \ + --manifest config/repository-metadata.json + metadata_apply_status=$? + python scripts/ci/reconcile_repository_labels.py \ + --taxonomy config/repository-label-taxonomy.json + label_apply_status=$? + python scripts/ci/reconcile_repository_labels.py \ + --taxonomy config/repository-label-taxonomy.json \ + --verify-only + label_verify_status=$? + + metadata_verify_status=1 + metadata_verify_attempt=1 + metadata_verify_limit=12 + while (( metadata_verify_attempt <= metadata_verify_limit )); do + metadata_verify_output="$( + python scripts/ci/reconcile_repository_metadata.py \ + --manifest config/repository-metadata.json \ + --verify-only 2>&1 + )" + metadata_verify_status=$? + printf '%s\n' "${metadata_verify_output}" + if (( metadata_verify_status == 0 )); then + break + fi + + metadata_failure_lines="$( + printf '%s\n' "${metadata_verify_output}" \ + | grep '^repository metadata reconciliation failed for ' || true + )" + if [[ -z "${metadata_failure_lines}" ]] \ + || printf '%s\n' "${metadata_failure_lines}" \ + | grep -Evq 'GitHub Pages (was not published|configuration did not converge|is not built|is not reachable)'; then + break + fi + if (( metadata_verify_attempt == metadata_verify_limit )); then + break + fi + sleep 15 + ((metadata_verify_attempt += 1)) + done + + set -e + if (( metadata_apply_status != 0 \ + || label_apply_status != 0 \ + || metadata_verify_status != 0 \ + || label_verify_status != 0 )); then + printf 'metadata_apply=%s label_apply=%s metadata_verify=%s label_verify=%s\n' \ + "${metadata_apply_status}" \ + "${label_apply_status}" \ + "${metadata_verify_status}" \ + "${label_verify_status}" >&2 + exit 1 + fi diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index 26d5d8b2cb..4452f7a93b 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -38,15 +38,13 @@ on: # path filters against the PR's full base..head diff, so a PR is skipped only # when EVERY changed file is a non-executable doc/image asset; any code, # config, build, or workflow change still triggers the scan. The run-name - # includes the PR number and head SHA for status grouping, while the - # concurrency group is scoped per repository and event class to prevent - # shared-provider key rate-limit storms. Strix runs intentionally do not - # cancel in progress because a pre-job cancellation leaves no scanner log to - # review. GitHub keeps one active and one pending run per group; the merge - # scheduler re-dispatches exact-head evidence when a pending run is - # superseded. For PRs the merge scheduler manages, same-head Strix evidence - # is still forced at merge time via repository_dispatch (which paths-ignore - # does not affect), so merged code never loses evidence. + # includes the PR number and head SHA for status grouping. Strix's expensive + # provider-backed job is serialized per repository/event class and never uses + # unordered same-PR native cancellation: delayed synchronize/closed deliveries + # must not be able to cancel a newer live-head run. The trusted merge scheduler + # owns predecessor retirement after live PR/head validation. Same-head Strix + # evidence is still forced at merge time via repository_dispatch (which + # paths-ignore does not affect), so merged code never loses evidence. paths-ignore: - '**/*.md' - '**/*.markdown' @@ -77,106 +75,12 @@ permissions: models: read jobs: - cancel-superseded-pr-runs: - if: github.event_name == 'pull_request_target' && (github.event.action == 'synchronize' || github.event.action == 'closed') - runs-on: ubuntu-latest - # Prefer the established scheduler credential, but let the close event use - # its job-scoped token so abandoned scans are cancelled even when that - # optional secret is unavailable. This job never checks out PR code. - permissions: - actions: write - contents: read - pull-requests: read - env: - GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} - TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} - TARGET_PR_NUMBER: ${{ github.event.pull_request.number }} - TARGET_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PR_ACTION: ${{ github.event.action }} - CURRENT_RUN_ID: ${{ github.run_id }} - steps: - - name: Cancel queued and running scans for superseded or closed pull request heads - shell: bash - run: | - set -euo pipefail - - live_target_matches() { - local live_pr_json live_action - if ! live_pr_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}" 2>/tmp/strix-cleanup-gh-error)"; then - echo "::warning::Strix cleanup could not verify the live pull request; leaving runs unchanged." - sed 's/^/ /' /tmp/strix-cleanup-gh-error >&2 || true - return 1 - fi - live_action="$(jq -r '[.state, .head.sha // ""] | @tsv' <<<"$live_pr_json")" - { [ "$PR_ACTION" = "closed" ] && [ "$live_action" = $'closed\t'"$TARGET_PR_HEAD_SHA" ]; } || - { [ "$PR_ACTION" = "synchronize" ] && [ "$live_action" = $'open\t'"$TARGET_PR_HEAD_SHA" ]; } - } - - cancel_runs() { - local status="$1" - if ! live_target_matches; then - echo "::notice::Strix cleanup target changed before run selection; leaving runs unchanged." - return 0 - fi - local runs_url="repos/${TARGET_REPOSITORY}/actions/runs?status=${status}&per_page=100" - local runs_json - if ! runs_json="$(gh api --paginate "$runs_url" 2>/tmp/strix-cleanup-gh-error)"; then - echo "::warning::Strix cleanup could not inspect ${TARGET_REPOSITORY}; leaving runs unchanged." - sed 's/^/ /' /tmp/strix-cleanup-gh-error >&2 || true - return 0 - fi - local run_ids - if ! run_ids="$(jq -r --arg pr "$TARGET_PR_NUMBER" --arg head_sha "$TARGET_PR_HEAD_SHA" \ - --arg action "$PR_ACTION" --arg repo "$TARGET_REPOSITORY" --arg current "$CURRENT_RUN_ID" ' - .workflow_runs[] - | select((.id | tostring) != $current) - | select(.name == "Strix Security Scan") - | select(.event == "pull_request_target") - | ((.display_title // "") | startswith("Strix Security Scan " + $repo + "#" + $pr + "@")) as $title_matches - | ((.pull_requests // []) | any((.number | tostring) == $pr)) as $metadata_matches - | select($title_matches or $metadata_matches) - | ((.display_title // "") | endswith("@" + $head_sha)) as $title_is_current - | ((.pull_requests // []) | any( - ((.number | tostring) == $pr) - and ((.head.sha // "") | ascii_downcase) == ($head_sha | ascii_downcase) - )) as $metadata_is_current - | ((.pull_requests // []) | any( - ((.number | tostring) == $pr) and ((.head.sha // "") != "") - )) as $metadata_has_head - | select( - $action == "closed" - or (($title_matches or $metadata_has_head) and (($title_is_current or $metadata_is_current) | not)) - ) - | .id - ' <<<"$runs_json")"; then - echo "::warning::Strix cleanup received invalid run data for ${TARGET_REPOSITORY}; leaving runs unchanged." - return 0 - fi - while IFS= read -r run_id; do - [ -n "$run_id" ] || continue - if ! live_target_matches; then - echo "::notice::Strix cleanup target changed before cancellation; leaving runs unchanged." - return 0 - fi - if gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null 2>/tmp/strix-cleanup-cancel-error || - gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/force-cancel" >/dev/null 2>>/tmp/strix-cleanup-cancel-error; then - echo "Cancelled obsolete Strix run ${run_id} in ${TARGET_REPOSITORY} for PR #${TARGET_PR_NUMBER}." - else - echo "::warning::Strix cleanup could not cancel run ${run_id} in ${TARGET_REPOSITORY}; it may have finished or the credential lacks Actions write access." - sed 's/^/ /' /tmp/strix-cleanup-cancel-error >&2 || true - fi - done <<<"$run_ids" - } - - for active_status in queued in_progress requested waiting pending; do - cancel_runs "$active_status" - done - strix: if: github.event_name != 'pull_request_target' || github.event.action != 'closed' concurrency: - # Keep provider-backed scans serial per repository and event class while - # allowing the trusted cleanup job above to retire an obsolete head now. + # Keep provider-backed scans serial per repository and event class. Same-PR + # predecessor/closed runs are retired by the trusted merge scheduler only + # after live PR/head validation, so delayed events cannot cancel newer work. group: >- strix-${{ (github.event_name == 'pull_request_target' || github.event_name == 'repository_dispatch') && @@ -199,6 +103,7 @@ jobs: contents: read id-token: write models: read + pull-requests: read statuses: write env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -209,6 +114,26 @@ jobs: egress-policy: audit disable-file-monitoring: true + - name: Validate live pull request before Strix setup + if: github.event_name == 'pull_request_target' + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + if ! pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Unable to revalidate live pull request before Strix setup." + exit 1 + fi + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + if [ "$live_state" != "open" ] || [ "$live_head_sha" != "$EXPECTED_HEAD_SHA" ]; then + echo "::error::Strix event is stale or the pull request is no longer open before setup." + exit 1 + fi + - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: @@ -583,6 +508,26 @@ jobs: echo 'enabled=true' >> "$GITHUB_OUTPUT" echo 'provider_mode=contextual_orchestrator' >> "$GITHUB_OUTPUT" + - name: Revalidate live pull request before provider execution + if: github.event_name == 'pull_request_target' + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + if ! pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Unable to revalidate live pull request before provider execution." + exit 1 + fi + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + if [ "$live_state" != "open" ] || [ "$live_head_sha" != "$EXPECTED_HEAD_SHA" ]; then + echo "::error::Strix event is stale or the pull request is no longer open before provider execution." + exit 1 + fi + - name: Provision contextual-orchestrator Strix sidecar if: steps.gate.outputs.enabled == 'true' env: @@ -899,8 +844,30 @@ jobs: echo "Strix reported security findings or failed for a non-backend reason; failing the required check (gate exit ${strix_rc})." >&2 exit "$strix_rc" + - name: Revalidate live pull request before evidence publication + id: live_publication + if: ${{ always() && github.event_name == 'pull_request_target' }} + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + if ! pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::Unable to revalidate live pull request before evidence publication." + exit 1 + fi + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + if [ "$live_state" != "open" ] || [ "$live_head_sha" != "$EXPECTED_HEAD_SHA" ]; then + echo "::error::Strix event is stale or the pull request is no longer open before evidence publication." + exit 1 + fi + echo "current=true" >> "$GITHUB_OUTPUT" + - name: Collect Strix reports for artifact upload - if: ${{ always() && steps.gate.outputs.enabled == 'true' }} + if: ${{ always() && steps.gate.outputs.enabled == 'true' && (github.event_name != 'pull_request_target' || steps.live_publication.outputs.current == 'true') }} env: PR_HEAD_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha }} run: | @@ -930,7 +897,7 @@ jobs: fi - name: Upload Strix reports artifact - if: ${{ always() && steps.gate.outputs.enabled == 'true' }} + if: ${{ always() && steps.gate.outputs.enabled == 'true' && (github.event_name != 'pull_request_target' || steps.live_publication.outputs.current == 'true') }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: strix-reports diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8038c3632e..565e90b086 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,6 +27,55 @@ flowchart LR Products -->|"standalone or as module"| Operator ``` +## Repository public-surface reconciliation + +Repository-facing metadata is an organization control-plane responsibility, +while product README content remains owned by each sibling repository. The +reviewed desired state lives in `config/repository-metadata.json` and +`config/repository-label-taxonomy.json`. Pull requests validate both manifests +and their reconciliation behavior without write authority. Scheduled apply +runs only from trusted `.github/main` after validation; branch-selected manual +dispatch is intentionally absent under the central workflow trust contract. + +```mermaid +flowchart TD + Desired["reviewed metadata + label desired state"] + Validate["read-only exact-revision validation"] + Preconditions{"leaf README badge / docs source live?"} + Apply["trusted protected-main apply"] + Repo["description + topics"] + Pages["Pages state"] + Labels["reviewed issue / PR labels"] + Verify["live public-state re-read"] + Hold["fail this leaf; continue siblings"] + + Desired --> Validate + Validate --> Preconditions + Preconditions -->|"no"| Hold + Preconditions -->|"yes"| Apply + Apply --> Repo + Apply --> Pages + Apply --> Labels + Repo --> Verify + Pages --> Verify + Labels --> Verify +``` + +The metadata reconciler is convergent: already-correct descriptions/topics and +legacy default-branch `/docs` Pages sites receive no write; absent or drifted +Pages state is created/updated, and disabled Pages is deleted. Topic equality +is set-based so GitHub presentation ordering cannot manufacture drift. Exact +DeepWiki badge state is a leaf-owned precondition, including a fail-closed +contradiction when desired state disables DeepWiki while the badge remains +live. Label reconciliation adds/removes only taxonomy-declared labels through +individual endpoints, preserving unrelated concurrent priority/status/area +labels. Metadata and label failures retain independent exit statuses, so a +blocked metadata leaf does not prevent eligible label work in the same apply. +Failures aggregate after independent repositories or assignments are attempted, +so one blocked leaf never serializes the fleet. Scheduled applies share a +ref-scoped lane and do not cancel active apply work midway. See ADR-0020 and the +operational baseline for the authority and live-verification contract. + ## OriginWeave hourly caller `originweave-hourly-review-repair.yml` is a thin, read-only caller at minute @@ -123,6 +172,9 @@ sequenceDiagram - Required review workflows execute **base-branch** scripts. A PR that edits those workflows cannot widen its own `pull_request_target` token. - Reviewer agents stay `edit: deny`. They judge; they do not implement. +- Repository public-surface writes execute only from trusted `.github/main`; + pull-request validation remains read-only and leaf README changes keep their + repository-local review boundary. - Central Semgrep binds one job-level `SEMGREP_IMAGE` digest for log evidence, manifest inspect, and `docker run` so buyers can reconstruct the exact scanner that produced SARIF. @@ -156,7 +208,10 @@ sequenceDiagram `scripts/ci/` ships with 100% statement/branch coverage and 100% docstrings. CI installs Python tools only with `pip install --require-hashes`. Contract tests pin workflow structure and governance prose so drift fails closed. The -trusted `uv` exporter is downloaded from the literal GitHub Releases URL for +repository-public-surface workflow additionally holds both reconciliation +scripts to 100% statement/branch coverage and 100% docstrings before its +privileged apply job can run. +The trusted `uv` exporter is downloaded from the literal GitHub Releases URL for `uv` 0.12.1; `releases.astral.sh` is not the network sink. An exact-base `uv.lock` may additionally expose source from an organization-owned GitHub repository pinned to a full commit: the secret-free image build verifies @@ -177,6 +232,10 @@ resolver conflict. — bot/agent exact-head review and merge procedure. - [`PR_GOVERNANCE_AUDIT.md`](PR_GOVERNANCE_AUDIT.md) — live review/merge contract. +- [`docs/adr/0020-repository-public-surface-reconciliation.md`](docs/adr/0020-repository-public-surface-reconciliation.md) + — desired-state ownership, trust boundary, and convergence decision. +- [`docs/doctoring/repository-public-surface-reconciliation.md`](docs/doctoring/repository-public-surface-reconciliation.md) + — current operational baseline and live-verification contract. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. - [`docs/doctoring/semgrep-image-digest-single-source.md`](docs/doctoring/semgrep-image-digest-single-source.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f0680a91d..8f980f794d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- **Refresh Noema reviewer App authority after long model work (`#1616`).** A real `naruon#1497` review outlived its repository-scoped GitHub App installation token and failed the next exact-head GitHub operation with HTTP 401. The trusted workflow now prepares the validated verdict into a private runner-local envelope, remints the same least-privilege repository-scoped App authority after model work, independently re-fetches exact live head/reviewer identity, and only then publishes. Skipped preparation creates no envelope, predecessor App tokens cannot authorize publication, PAT/OIDC remain explicit fail-closed sources, malformed handoffs are cleaned up, and executable plus step-scoped regressions cover stale-head, identity, alias, workflow wiring, and migration of legacy broader-suite contracts away from the retired single-process reviewer path. - Fix `existing_noema_review()` treating a "legacy" Noema review (one posted before `NOEMA_REVIEW_FOOTER_MARKER` existed) as proof the current head was already reviewed. `noema_review_handoff.py`'s `noema_review_state()` can never recognize such a review as a diff --git a/config/repository-label-taxonomy.json b/config/repository-label-taxonomy.json new file mode 100644 index 0000000000..a1831221ed --- /dev/null +++ b/config/repository-label-taxonomy.json @@ -0,0 +1,105 @@ +{ + "schema_version": 1, + "type": { + "feature": "enhancement", + "bug": "bug", + "documentation": "documentation" + }, + "assignments": [ + { + "repository": ".github", + "issue": 1582, + "type": "feature" + }, + { + "repository": "CalendarWeave", + "issue": 1, + "type": "documentation" + }, + { + "repository": "ConceptWeave", + "issue": 1, + "type": "feature" + }, + { + "repository": "context-graph-contracts", + "issue": 20, + "type": "documentation" + }, + { + "repository": "RankWeave", + "issue": 40, + "type": "documentation" + }, + { + "repository": "fast-mlsirm", + "issue": 1717, + "type": "documentation" + }, + { + "repository": "EgressWeave", + "issue": 231, + "type": "documentation" + }, + { + "repository": "psychometrics-commons", + "issue": 442, + "type": "documentation" + }, + { + "repository": "contextual-orchestrator", + "issue": 994, + "type": "documentation" + }, + { + "repository": "contextual-orchestrator", + "issue": 1003, + "type": "documentation" + }, + { + "repository": "appguardrail", + "issue": 1077, + "type": "documentation" + }, + { + "repository": "naruon", + "issue": 1513, + "type": "documentation" + }, + { + "repository": "LineageWeave", + "issue": 908, + "type": "documentation" + }, + { + "repository": "ContextualWisdomLab.github.io", + "issue": 203, + "type": "documentation" + }, + { + "repository": "TEPP", + "issue": 435, + "type": "documentation" + }, + { + "repository": "semantic-data-portal", + "issue": 72, + "type": "documentation" + }, + { + "repository": "Orgmetra", + "issue": 160, + "type": "documentation" + }, + { + "repository": "learning-interoperability-contracts", + "issue": 1, + "type": "feature" + }, + { + "repository": "noema", + "issue": 530, + "type": "feature" + } + ] +} diff --git a/config/repository-metadata.json b/config/repository-metadata.json new file mode 100644 index 0000000000..fcf8471236 --- /dev/null +++ b/config/repository-metadata.json @@ -0,0 +1,54 @@ +{ + "schema_version": 1, + "organization": "ContextualWisdomLab", + "repositories": { + "CalendarWeave": { + "description": "CalendarWeave — governed calendar resources, iCalendar semantics, and interoperable scheduling infrastructure.", + "topics": ["calendar", "caldav", "icalendar", "scheduling", "rust", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + }, + "ConceptWeave": { + "description": "ConceptWeave — turn enterprise data into governed semantic models and reusable meaning.", + "topics": ["semantic-model", "ontology", "knowledge-graph", "data-governance", "rust", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + }, + "context-graph-contracts": { + "description": "Context Graph Contracts — versioned interoperability contracts for context, lineage, provenance, and architecture facts.", + "topics": ["interoperability", "json-schema", "asyncapi", "cloudevents", "provenance", "context-graph", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + }, + "ThreadWeave": { + "description": "ThreadWeave — standards-grounded, deterministic email conversation threading for Python.", + "topics": ["email", "threading", "imap", "rfc5256", "python", "mail", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + }, + "RankWeave": { + "description": "RankWeave — deterministic retrieval fusion, evaluation, statistical comparison, and auditable ranking workflows for Python.", + "topics": ["information-retrieval", "ranking", "retrieval", "reciprocal-rank-fusion", "trec", "python", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + }, + "fast-mlsirm": { + "description": "fast-mlsirm — high-performance psychometric modeling, calibration, and evaluation with a Rust numerical core.", + "topics": ["irt", "item-response-theory", "mlsirm", "psychometrics", "calibration", "measurement", "rust", "python", "simulation", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + }, + "EgressWeave": { + "description": "EgressWeave — SSRF- and DNS-rebinding-safe outbound HTTP for Python.", + "topics": ["egress", "ssrf", "dns-rebinding", "http", "network-security", "httpx", "python", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + }, + "psychometrics-commons": { + "description": "Psychometrics Commons — governed psychometric assessment, longitudinal measurement, and consent-aware research workflows.", + "topics": ["psychometrics", "assessment", "measurement", "longitudinal", "research", "privacy", "rust", "contextualwisdomlab"], + "deepwiki": true, + "pages": true + } + } +} diff --git a/docs/adr/0020-repository-public-surface-reconciliation.md b/docs/adr/0020-repository-public-surface-reconciliation.md new file mode 100644 index 0000000000..6968985521 --- /dev/null +++ b/docs/adr/0020-repository-public-surface-reconciliation.md @@ -0,0 +1,41 @@ +# ADR-0020: Reconcile repository public surfaces from reviewed desired state + +- **Status:** Accepted +- **Date:** 2026-09-01 +- **Scope:** ContextualWisdomLab organization repository-facing metadata and classification + +## Context + +Repository descriptions, topics, GitHub Pages settings, DeepWiki badges, and issue/PR labels are customer- and maintainer-visible product surfaces. The connected automation client can read these surfaces but does not expose every repository-settings mutation directly. Repeated one-off edits also create drift, casing mistakes, duplicate badges, contradictory Pages intent, and inconsistent labels. + +The organization therefore needs one auditable owner for the desired state and one convergent reconciliation path. README prose remains owned by each product repository because it must be reviewed together with that product's actual behavior. Repository settings and cross-repository label normalization belong in the organization control plane. + +## Decision + +1. `config/repository-metadata.json` is the reviewed desired state for exact repository casing, concise public descriptions, normalized topics, exact DeepWiki intent, and GitHub Pages intent. +2. `config/repository-label-taxonomy.json` defines the small semantic label vocabulary and explicit repository/issue assignments. The reconciler manages only labels named by that vocabulary and preserves unrelated priority, status, area, and workflow labels. +3. `scripts/ci/reconcile_repository_metadata.py` applies description, topics, and Pages settings only after repository-local preconditions are present on the protected default branch. It aggregates repository failures so one blocked leaf does not prevent independent repositories from being attempted. +4. `scripts/ci/reconcile_repository_labels.py` applies only reviewed label assignments. It mutates taxonomy-managed labels through individual label endpoints, is idempotent, preserves unrelated concurrent labels, and aggregates assignment failures for the same non-blocking fleet behavior. +5. DeepWiki README content is not mutated centrally. `deepwiki: true` requires the exact linked badge on the default branch before metadata writes; `deepwiki: false` fails closed while that exact badge is still present so desired state cannot silently contradict the public README. +6. Pages uses GitHub's legacy branch source on the repository default branch at `/docs`. Creation occurs only when no site exists; update occurs only when branch, path, or build type differs; disable deletes an existing site. A converged Pages site receives no hourly write. +7. Pull-request execution is read-only validation. Privileged reconciliation runs only from trusted `.github/main`, uses the existing maintainer credential, does not widen pull-request tokens, and does not bypass repository rulesets or reviews. +8. Reconciliation runs from the trusted hourly schedule and exposes no branch-selectable `workflow_dispatch` entrypoint. Ref-scoped concurrency does not cancel an active apply midway, so partial fleet state is completed by the active run rather than being abandoned by a replacement run. +9. Metadata and label lanes retain independent exit statuses during apply: label reconciliation still runs after an aggregated metadata failure, and the job fails afterward if either lane failed. +10. Repository-wide tests, focused 100% statement/branch coverage for both reconciliation scripts, docstring gates, manifest/taxonomy validation, and `git diff --check` are required before apply can run. + +## Consequences + +- Public metadata becomes declarative, reviewable, repeatable, and convergent instead of depending on ad-hoc connector capabilities. +- A leaf repository can block only its own unsafe mutation; other eligible repositories continue in the same invocation. +- Exact README and Pages preconditions make a source commit insufficient evidence of publication. Live repository metadata and Pages state must be re-read after apply before publication is claimed. +- Explicit label assignments intentionally favor evidence over broad title heuristics. Expanding classification coverage requires a reviewed assignment or a separately justified deterministic classifier. +- The privileged token must retain only the repository-administration/Pages/issue permissions required by the declared fleet. Credential values never enter the manifest or logs. + +## Rejected alternatives + +- **Report missing connector mutations without repair.** Rejected because the organization owns a GitHub Actions/API control plane that can safely provide the capability. +- **Mutate README badges from the central control plane.** Rejected because that would bypass the active product writer and make customer-facing content independent of product review. +- **Expose branch-selected manual dispatch.** Rejected because the central control-plane contract requires manual entrypoints not to load branch-selected code. +- **Replace an issue's entire label list.** Rejected because stale read-modify-write can erase unrelated labels added concurrently by humans or automation. +- **Rewrite Pages every hour.** Rejected because a converged desired-state reconciler must have a write-free steady state. +- **Infer issue type from title prefixes alone.** Rejected because classification needs evidence and must preserve richer repository-local workflow labels. diff --git a/docs/doctoring/noema-review-token-lifetime.md b/docs/doctoring/noema-review-token-lifetime.md new file mode 100644 index 0000000000..5346333ee2 --- /dev/null +++ b/docs/doctoring/noema-review-token-lifetime.md @@ -0,0 +1,21 @@ +# Noema reviewer credential lifetime + +## Incident and root cause + +On 2026-09-01, trusted central Noema review for `ContextualWisdomLab/naruon#1497@152d1998c4e8024be9dc7026c8789d343c884fd0` minted the repository-scoped `cwl-noema-review` GitHub App installation token before model work. Contextual-orchestrator review then exceeded the installation-token lifetime; the first later GitHub operation failed HTTP 401 and cleanup independently reported token expiry. Repository-owned deterministic checks on that Naruon head were otherwise green. The defect is in the central reviewer credential lifecycle, not Naruon product code. + +## Closed operating contract + +Noema separates model verdict preparation from GitHub publication. Preparation remains bound to the trigger's canonical exact head and the exact base commit that defined the reviewed diff/context, and stores only a bounded, owner-only, single-link runner-local envelope. If preparation intentionally skips because the PR is stale, draft, or already reviewed, the workflow emits `prepared=false` and performs no publication. + +For the GitHub App path, a second repository-scoped installation token is minted only after model work and only when a publishable envelope exists. Publication never reuses the predecessor App token, never falls back to `github.token` or the PR author, and independently re-fetches the live PR/head/base and reviewer actor before submitting evidence. A base-branch advance with an unchanged PR head invalidates the prepared verdict because the changed-file diff and review context may have changed; such predecessor-base evidence is consumed without publication. PAT and OIDC remain explicit sources: publication uses only the selected source and fails closed if it is absent; this repair does not silently convert those paths to another authority. + +The envelope is deleted after every publication attempt, including malformed-envelope read validation failures. Executable regressions cover preparation-without-publication, exact-head/base/actor rebinding, stale heads, base drift with an unchanged head, draft skip behavior, cleanup, and hard-link alias rejection. Step-scoped workflow regressions prove that the second App mint sits between preparation and publication and that publication references the fresh token. + +## Verification and downstream replay + +Focused CI runs the token-lifetime and two-phase handoff regressions with hash-pinned review dependencies whenever the workflow/helper/contracts change. After protected-main merge, replay unchanged `naruon#1497@152d1998c4e8024be9dc7026c8789d343c884fd0`: Required Noema Review must finish with current-head-and-base schema-valid review evidence or a typed review-unavailable result, never opaque expired-token 401 and never stale-head/base publication. A pre-merge run does not prove the merged workflow-source path and is not promoted to release evidence. + +### Regression-suite migration + +The two-phase migration also updates pre-existing executable workflow contracts to target the `Prepare Noema model verdict` step and the explicit prepare/publish helper invocations. This prevents a green focused gate from coexisting with stale broader-suite expectations for the retired single-process command or step name. diff --git a/docs/doctoring/repository-public-surface-reconciliation.md b/docs/doctoring/repository-public-surface-reconciliation.md new file mode 100644 index 0000000000..4a1a79a477 --- /dev/null +++ b/docs/doctoring/repository-public-surface-reconciliation.md @@ -0,0 +1,74 @@ +# Repository public-surface reconciliation — operational baseline + +**Recorded:** 2026-09-01 +**Owner:** `ContextualWisdomLab/.github` +**Applies to:** repository descriptions, topics, GitHub Pages settings, exact Ask DeepWiki preconditions, and reviewed issue/PR label assignments. + +## Problem statement + +The organization had repository-facing state that could be observed but not consistently mutated through the connected GitHub client. Concrete examples included an internal-instruction-heavy CalendarWeave description, empty repository topics on new bounded-context repositories, `has_pages=false` despite reviewed documentation sources being prepared, and label normalization that depended on one-off manual edits. A second central metadata PR also created a competing writer for the same control-plane responsibility. + +Reporting those limitations was insufficient because the organization already owns a central GitHub Actions/API control plane. The repair therefore belongs in `.github`: reviewed desired state plus a least-privilege, protected-default-branch reconciliation path. + +## Current control loop + +```mermaid +flowchart TD + Manifest["repository-metadata.json"] + Taxonomy["repository-label-taxonomy.json"] + Validate["read-only PR validation"] + Leaf["leaf README + docs/index.md on default branch"] + Apply["trusted .github/main apply"] + Metadata["description + topics"] + Pages["Pages create/update/delete only on drift"] + Labels["reviewed issue/PR label assignments"] + Verify["re-read live public state"] + + Manifest --> Validate + Taxonomy --> Validate + Leaf --> Validate + Validate --> Apply + Apply --> Metadata + Apply --> Pages + Apply --> Labels + Metadata --> Verify + Pages --> Verify + Labels --> Verify +``` + +The fleet loop is deliberately non-blocking. Every repository or label assignment is attempted independently, failures are collected, and the process reports the aggregate only after reachable siblings have been tried. A missing leaf README badge or Pages source therefore blocks only that repository's public-setting mutation. + +## Safety and authority + +- Pull-request validation has `contents: read` only. It cannot mutate repository settings or labels. +- Apply runs only when the scheduled workflow is executing from trusted `refs/heads/main` after validation. +- The apply step uses the established maintainer credential rather than widening the ordinary workflow token. +- Repository README changes remain leaf-owned. The central reconciler verifies exact DeepWiki linkage but never fabricates or silently edits customer-facing README copy. +- Pages publication is conditional on `docs/index.md` being present on the live default branch. A branch-only source or PR is not publication evidence. +- Pages is convergent: absent sites are created, drifted legacy `/docs` sites are updated, disabled sites are deleted, and already-correct sites receive no write. +- Label reconciliation adds and removes only taxonomy-managed labels through individual label endpoints, so unrelated labels added by people or automation are not replaced from a stale snapshot. +- Scheduled reconciliation does not cancel an active apply, preventing a replacement run from abandoning a partially updated fleet. +- The repository's control-plane contract intentionally exposes no branch-selectable `workflow_dispatch` entrypoint; remediation follows the trusted default-branch schedule and normal rerun/governance paths. + +## Desired-state fleet in this increment + +The repository metadata manifest currently covers eight repositories selected because their public-surface work already has a concrete leaf source or active writer: `CalendarWeave`, `ConceptWeave`, `context-graph-contracts`, `ThreadWeave`, `RankWeave`, `fast-mlsirm`, `EgressWeave`, and `psychometrics-commons`. EgressWeave and Psychometrics Commons joined the fleet after their exact-cased DeepWiki badges and bounded `docs/index.md` Pages sources reached their protected default branches. + +The explicit label assignments now cover 19 evidence-backed targets: `.github#1582`, `CalendarWeave#1`, `ConceptWeave#1`, `context-graph-contracts#20`, `RankWeave#40`, `fast-mlsirm#1717`, `EgressWeave#231`, `psychometrics-commons#442`, `contextual-orchestrator#994`, `contextual-orchestrator#1003`, `appguardrail#1077`, `naruon#1513`, `LineageWeave#908`, `ContextualWisdomLab.github.io#203`, `TEPP#435`, `semantic-data-portal#72`, `Orgmetra#160`, `learning-interoperability-contracts#1`, and `noema#530`. The assignment reconciler preserves richer repository-local labels such as priority, status, and `type: maintenance` when those labels are outside the managed semantic set. + +## Verification contract + +A central source commit is not completion. After protected integration and apply, the operator or automation must re-read each affected repository and verify: + +1. the live description equals reviewed desired state; +2. live topics equal the normalized desired set; +3. the default-branch README carries the exact linked DeepWiki badge when requested; +4. `docs/index.md` exists on the live default branch before Pages is enabled; +5. the live Pages configuration uses the intended default branch and `/docs`, and the published site is reachable before publication is claimed; +6. reviewed issue/PR targets carry the desired managed label while unrelated labels remain intact. + +GitHub's current REST Pages contract supports `build_type` values `legacy` and `workflow`, and branch sources with `/` or `/docs`. The reconciler selects `legacy` plus `/docs` because the leaf repositories provide reviewed static documentation sources rather than a separate custom Pages workflow. + +## Known integration boundary + +Until the central PR is merged through normal governance, the settings reconciliation cannot run from trusted `.github/main`; leaf PRs whose badge or Pages source is still branch-only also remain repository-local precondition blockers. These are integration states, not reasons to stop independent repository work. The same run should continue classifying labels, preparing other leaf public surfaces, and re-checking earlier lanes when exact-head evidence becomes available. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 6a2bf678d4..7ba1d7cd41 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -2562,3 +2562,17 @@ Zhang, S., Yu, Y., Li, Y., Zhao, W., Yang, Y., Zhang, Y., & Liu, T. (2025). *Con Xu, J., Sun, Q., Schwendeman, P., Nielsen, S., Cetin, E., & Tang, Y. (2026). *TRINITY: An evolved LLM coordinator* [Preprint]. arXiv. https://doi.org/10.48550/arXiv.2512.04695 Higgins, S. S., Crepalde, N., & Fernandes, L. (2021). Segmented multiplexity: A research agenda for multiplexity beyond the average. *PLOS ONE, 16*(9), e0257527. https://doi.org/10.1371/journal.pone.0257527 + + +## Noema reviewer credential-lifetime delta — 2026-09-01 + +**Observed gap.** `ContextualWisdomLab/naruon#1497@152d1998c4e8024be9dc7026c8789d343c884fd0` demonstrated a control-plane latency/authority defect: a repository-scoped `cwl-noema-review` GitHub App token minted before contextual-orchestrator model work expired before the next GitHub operation, producing HTTP 401 even though repository-owned deterministic checks were otherwise successful. This is a central `.github` reviewer-lifecycle gap, not a Naruon product failure. + +**Owner-side closure in #1616.** The Noema workflow now treats model preparation and GitHub publication as separate trust phases. A bounded private envelope carries only the model verdict; the GitHub App path remints the same repository-scoped least-privilege authority after model work, and publication independently verifies repository, PR number, canonical exact head, live PR state, draft state, independent reviewer actor, and duplicate-current-head review state before submission. No predecessor-head evidence or predecessor App credential is accepted as publication authority. PAT/OIDC remain explicit sources and there is no `github.token` or author fallback. + +**Executable evidence.** `tests/test_noema_reviewer_token_lifetime.py` binds the production workflow step graph to prepare → fresh App mint → publish with exact-head arguments and source-specific credentials. `tests/test_noema_two_phase_handoff.py` executes the helper against controlled gate doubles and proves no preparation-side publication, fresh-head/actor rebinding, stale-head non-publication, draft skip behavior, cleanup on malformed handoff, and hard-link alias rejection. `.github/workflows/noema-token-lifetime-quality-ci.yml` runs these contracts with hash-pinned dependencies on every relevant seam. + + +**Regression-suite consistency.** Legacy broader-suite assertions that still named the retired single-process Noema step/module are migrated to the two-phase prepare/publish contract, including step-scoped helper and envelope-argument evidence. This closes the false-GREEN gap where focused token-lifetime CI could pass while unchanged broader contracts described an impossible execution path. + +**Residual external verification.** After this central change reaches protected `main`, replay Required Noema Review for unchanged `naruon#1497@152d1998c4e8024be9dc7026c8789d343c884fd0`. Closure evidence requires a current-head schema-valid review or typed review-unavailable outcome without expired-token 401; a pre-merge run cannot prove the merged workflow-source path and is not promoted to release evidence. diff --git a/fuzz/fuzz_opencode_normalize_output.py b/fuzz/fuzz_opencode_normalize_output.py deleted file mode 100644 index 0e034a2ee2..0000000000 --- a/fuzz/fuzz_opencode_normalize_output.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Atheris fuzz harness for OpenCode review-output normalization.""" - -from __future__ import annotations - -import importlib.util -import pathlib -import sys - -import atheris - - -REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] -NORMALIZER_PATH = REPO_ROOT / "scripts" / "ci" / "opencode_review_normalize_output.py" - - -def _load_normalizer(): - """Load the normalizer module without requiring package installation.""" - spec = importlib.util.spec_from_file_location( - "opencode_review_normalize_output", NORMALIZER_PATH - ) - if spec is None or spec.loader is None: - raise RuntimeError("Could not load OpenCode normalizer module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -NORMALIZER = _load_normalizer() - - -def TestOneInput(data: bytes) -> None: - """Feed arbitrary model text into the JSON extraction path.""" - try: - text = data.decode("utf-8", errors="ignore") - NORMALIZER.extract_json_object(text) - except (ValueError, UnicodeError): - return - - -def main() -> None: - """Run the Atheris entry point.""" - atheris.Setup(sys.argv, TestOneInput) - atheris.Fuzz() - - -if __name__ == "__main__": - main() diff --git a/scripts/ci/install_python_requirements_for_coverage.py b/scripts/ci/install_python_requirements_for_coverage.py deleted file mode 100644 index 3f29ef18c5..0000000000 --- a/scripts/ci/install_python_requirements_for_coverage.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Install target Python requirements for coverage evidence with visible policy logs.""" - -from __future__ import annotations - -import argparse -import pathlib -import shutil -import subprocess -import sys - - -def _requirement_lines(path: pathlib.Path) -> list[str]: - """Return non-empty, non-comment requirement lines.""" - lines: list[str] = [] - for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - lines.append(line) - return lines - - -def _has_hash_pins(path: pathlib.Path) -> bool: - """Return whether a requirements file carries hash-checking intent.""" - lines = _requirement_lines(path) - if not lines: - return True - return any(line == "--require-hashes" for line in lines) or all( - "--hash=" in line or line.startswith(("-r ", "--requirement ")) - for line in lines - ) - - -def _run(command: list[str], cwd: pathlib.Path) -> int: - """Run one installer command from a target project directory.""" - print("+ " + " ".join(command), flush=True) - return subprocess.run(command, cwd=cwd, check=False).returncode - - -def main(argv: list[str] | None = None) -> int: - """Install one target requirements file under the coverage policy.""" - parser = argparse.ArgumentParser() - parser.add_argument("requirements", type=pathlib.Path) - args = parser.parse_args(argv) - - requirements = args.requirements.resolve() - if not requirements.is_file(): - print(f"::error::requirements file not found: {requirements}", file=sys.stderr) - return 2 - - cwd = requirements.parent - if _has_hash_pins(requirements): - print( - f"Installing hash-pinned Python requirements from {requirements}.", - flush=True, - ) - return _run( - [ - sys.executable, - "-m", - "pip", - "install", - "--disable-pip-version-check", - "--require-hashes", - "-r", - str(requirements), - ], - cwd, - ) - - uv = shutil.which("uv") - if uv: - print( - "::warning::Target requirements are not hash-pinned; using uv for " - "coverage-only dependency materialization in a read-only/no-secret job.", - flush=True, - ) - return _run([uv, "pip", "install", "--system", "-r", str(requirements)], cwd) - - print( - "::error::Target requirements are not hash-pinned and uv is unavailable; " - "refusing unpinned pip install. Add --hash pins or a lock-backed pyproject " - "so coverage evidence can install dependencies safely.", - file=sys.stderr, - ) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/ci/reconcile_repository_labels.py b/scripts/ci/reconcile_repository_labels.py new file mode 100644 index 0000000000..d4585877c6 --- /dev/null +++ b/scripts/ci/reconcile_repository_labels.py @@ -0,0 +1,269 @@ +"""Reconcile evidence-backed GitHub labels from a reviewed organization taxonomy.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any +from urllib.parse import quote + + +ORGANIZATION = "ContextualWisdomLab" +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+$") + + +class TaxonomyError(ValueError): + """Raised when the reviewed label taxonomy is malformed or ambiguous.""" + + +def _plain_dict(value: Any, *, field: str) -> dict[str, Any]: + """Return an exact dictionary or reject behavior-bearing mapping objects.""" + + if type(value) is not dict: + raise TaxonomyError(f"{field} must be an object") + return value + + +def load_taxonomy(path: Path) -> tuple[dict[str, str], list[dict[str, Any]]]: + """Load and validate semantic label mappings and explicit assignments.""" + + root = _plain_dict(json.loads(path.read_text(encoding="utf-8")), field="taxonomy") + if set(root) != {"schema_version", "type", "assignments"}: + raise TaxonomyError("taxonomy has an unexpected key set") + if type(root["schema_version"]) is not int or root["schema_version"] != 1: + raise TaxonomyError("taxonomy schema is unsupported") + raw_types = _plain_dict(root["type"], field="type") + if not raw_types: + raise TaxonomyError("type mappings must not be empty") + type_map: dict[str, str] = {} + for semantic_type, label in raw_types.items(): + if ( + type(semantic_type) is not str + or not semantic_type + or type(label) is not str + or not label + ): + raise TaxonomyError("type mappings must use non-empty strings") + type_map[semantic_type] = label + if len({label.casefold() for label in type_map.values()}) != len(type_map): + raise TaxonomyError("managed labels must be unique ignoring case") + + raw_assignments = root["assignments"] + if type(raw_assignments) is not list: + raise TaxonomyError("assignments must be an array") + assignments: list[dict[str, Any]] = [] + seen: set[tuple[str, int]] = set() + casing_by_identity: dict[str, str] = {} + for index, raw in enumerate(raw_assignments): + assignment = _plain_dict(raw, field=f"assignments[{index}]") + if set(assignment) != {"repository", "issue", "type"}: + raise TaxonomyError(f"assignments[{index}] has an unexpected key set") + repository = assignment["repository"] + issue = assignment["issue"] + semantic_type = assignment["type"] + if type(repository) is not str or not REPOSITORY_RE.fullmatch(repository): + raise TaxonomyError(f"assignments[{index}].repository is invalid") + if type(issue) is not int or issue < 1: + raise TaxonomyError(f"assignments[{index}].issue is invalid") + if semantic_type not in type_map: + raise TaxonomyError(f"assignments[{index}].type is unknown") + identity = repository.casefold() + prior = casing_by_identity.get(identity) + if prior is not None and prior != repository: + raise TaxonomyError( + f"repository casing collision: {prior} and {repository} identify the same GitHub repository" + ) + casing_by_identity[identity] = repository + key = (identity, issue) + if key in seen: + raise TaxonomyError("assignments contain duplicate repository/issue targets") + seen.add(key) + assignments.append( + {"repository": repository, "issue": issue, "type": semantic_type} + ) + return type_map, assignments + + +def _gh_api( + method: str, + endpoint: str, + *, + body: Any = None, + allow_not_found: bool = False, +) -> str: + """Call GitHub CLI with bounded JSON and optional idempotent 404 handling.""" + + command = ["gh", "api", "--method", method, endpoint] + if body is not None: + command.extend(["--input", "-"]) + completed = subprocess.run( + command, + check=False, + input=None if body is None else json.dumps(body, separators=(",", ":")), + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode != 0: + combined = f"{completed.stdout}\n{completed.stderr}" + if allow_not_found and ("HTTP 404" in combined or "Not Found" in combined): + return "" + raise RuntimeError(f"GitHub API request failed for {endpoint}") + return completed.stdout + + +def _label_names(payload: dict[str, Any]) -> list[str]: + """Extract a stable label-name list from an issue or pull-request payload.""" + + raw_labels = payload.get("labels", []) + if type(raw_labels) is not list: + raise RuntimeError("GitHub issue labels payload is malformed") + names: list[str] = [] + seen: set[str] = set() + for raw in raw_labels: + if type(raw) is str: + name = raw + elif type(raw) is dict and type(raw.get("name")) is str: + name = raw["name"] + else: + raise RuntimeError("GitHub issue label entry is malformed") + identity = name.casefold() + if identity not in seen: + seen.add(identity) + names.append(name) + return names + + +def _managed_labels( + assignment: dict[str, Any], type_map: dict[str, str] +) -> tuple[str, set[str], str]: + """Return issue endpoint, managed casefold identities, and desired label.""" + + repository = assignment["repository"] + issue = assignment["issue"] + desired_label = type_map[assignment["type"]] + endpoint = f"repos/{ORGANIZATION}/{repository}/issues/{issue}" + return endpoint, {label.casefold() for label in type_map.values()}, desired_label + + +def reconcile_assignment( + assignment: dict[str, Any], type_map: dict[str, str] +) -> None: + """Mutate only taxonomy labels and preserve concurrent unrelated labels.""" + + endpoint, managed, desired_label = _managed_labels(assignment, type_map) + payload = _plain_dict(json.loads(_gh_api("GET", endpoint)), field="GitHub issue") + current = _label_names(payload) + desired_identity = desired_label.casefold() + obsolete = [ + label + for label in current + if label.casefold() in managed and label.casefold() != desired_identity + ] + missing_desired = desired_identity not in {label.casefold() for label in current} + if not obsolete and not missing_desired: + return + + if missing_desired: + _gh_api("POST", f"{endpoint}/labels", body={"labels": [desired_label]}) + for label in obsolete: + encoded_label = quote(label, safe="") + _gh_api( + "DELETE", + f"{endpoint}/labels/{encoded_label}", + allow_not_found=True, + ) + + verify_assignment(assignment, type_map) + + +def verify_assignment(assignment: dict[str, Any], type_map: dict[str, str]) -> None: + """Re-read one target and fail unless its managed labels exactly converge.""" + + endpoint, managed, desired_label = _managed_labels(assignment, type_map) + payload = _plain_dict(json.loads(_gh_api("GET", endpoint)), field="GitHub issue") + current = _label_names(payload) + managed_after = {label.casefold() for label in current if label.casefold() in managed} + if managed_after != {desired_label.casefold()}: + repository = assignment["repository"] + issue = assignment["issue"] + raise RuntimeError( + f"managed labels did not converge for {repository}#{issue}" + ) + + +def parse_args() -> argparse.Namespace: + """Parse validation, verification, and narrow repository selection arguments.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--taxonomy", type=Path, required=True) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--validate-only", action="store_true") + mode.add_argument("--verify-only", action="store_true") + parser.add_argument("--repository", action="append", default=[]) + return parser.parse_args() + + +def _select_repository_identities( + requested: list[str], assignments: list[dict[str, Any]] +) -> set[str]: + """Canonicalize filters by case-insensitive GitHub repository identity.""" + + if not requested: + return set() + canonical_by_identity = { + assignment["repository"].casefold(): assignment["repository"] + for assignment in assignments + } + selected: set[str] = set() + unknown: list[str] = [] + for candidate in requested: + identity = candidate.casefold() + if identity not in canonical_by_identity: + unknown.append(candidate) + else: + selected.add(identity) + if unknown: + raise TaxonomyError(f"undeclared repositories requested: {', '.join(sorted(unknown))}") + return selected + + +def main() -> int: + """Validate, reconcile, or verify every independent assignment possible.""" + + args = parse_args() + type_map, assignments = load_taxonomy(args.taxonomy) + if args.validate_only: + return 0 + if not os.environ.get("GH_TOKEN"): + raise RuntimeError("GH_TOKEN is required outside validation mode") + + selected = _select_repository_identities(args.repository, assignments) + operation = verify_assignment if getattr(args, "verify_only", False) else reconcile_assignment + failures: list[str] = [] + for assignment in assignments: + if selected and assignment["repository"].casefold() not in selected: + continue + try: + operation(assignment, type_map) + except ( + TaxonomyError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: + target = f'{assignment["repository"]}#{assignment["issue"]}' + failures.append(f"{target}: {exc}") + print(f"label reconciliation failed for {target}: {exc}", file=sys.stderr) + if failures: + raise RuntimeError("label reconciliation failed: " + "; ".join(failures)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/reconcile_repository_metadata.py b/scripts/ci/reconcile_repository_metadata.py new file mode 100644 index 0000000000..4f2e649253 --- /dev/null +++ b/scripts/ci/reconcile_repository_metadata.py @@ -0,0 +1,463 @@ +"""Reconcile public GitHub repository metadata from a reviewed desired-state manifest. + +The reconciler is intentionally narrow: it changes repository descriptions, +repository topics, and GitHub Pages settings. README content remains owned by +the target repository so badge/content changes can pass through that +repository's normal review path. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any +from urllib.error import URLError +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +ORGANIZATION = "ContextualWisdomLab" +REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +TOPIC_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,49}$") +MAX_DESCRIPTION_CHARS = 350 +PAGES_BASE_URL = f"https://{ORGANIZATION.casefold()}.github.io" + + +class ManifestError(ValueError): + """Raised when desired repository metadata is malformed or unsafe.""" + + +class _NoPagesRedirects(HTTPRedirectHandler): + """Refuse redirects so Pages verification cannot be redirected off GitHub Pages.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + """Return no follow-up request for any redirect.""" + + return None + + +def _require_exact_dict(value: Any, *, field: str) -> dict[str, Any]: + """Return a plain dictionary or reject behavior-bearing mapping objects.""" + + if type(value) is not dict: + raise ManifestError(f"{field} must be an object") + return value + + +def _validate_repository(name: str, raw: Any) -> dict[str, Any]: + """Validate one repository desired-state record and return a safe snapshot.""" + + if not isinstance(name, str) or not REPOSITORY_RE.fullmatch(name): + raise ManifestError("repository names must preserve exact GitHub-safe casing") + item = _require_exact_dict(raw, field=f"repositories.{name}") + expected = {"description", "topics", "deepwiki", "pages"} + if set(item) != expected: + raise ManifestError(f"repositories.{name} must contain exactly {sorted(expected)}") + + description = item["description"] + if ( + type(description) is not str + or not description.strip() + or len(description) > MAX_DESCRIPTION_CHARS + ): + raise ManifestError(f"repositories.{name}.description is invalid") + lowered = description.lower() + if ( + "do not " in lowered + or "#" in description + or "http://" in lowered + or "https://" in lowered + ): + raise ManifestError( + f"repositories.{name}.description contains internal-facing or navigational text" + ) + + topics = item["topics"] + if type(topics) is not list or not 1 <= len(topics) <= 20: + raise ManifestError(f"repositories.{name}.topics must contain 1..20 topics") + if any( + type(topic) is not str or not TOPIC_RE.fullmatch(topic) for topic in topics + ): + raise ManifestError(f"repositories.{name}.topics contains an invalid topic") + if len(set(topics)) != len(topics): + raise ManifestError(f"repositories.{name}.topics contains duplicates") + + if type(item["deepwiki"]) is not bool or type(item["pages"]) is not bool: + raise ManifestError( + f"repositories.{name} deepwiki/pages flags must be booleans" + ) + return { + "description": description, + "topics": list(topics), + "deepwiki": item["deepwiki"], + "pages": item["pages"], + } + + +def load_manifest(path: Path) -> dict[str, dict[str, Any]]: + """Load and validate the complete desired-state manifest.""" + + payload = json.loads(path.read_text(encoding="utf-8")) + root = _require_exact_dict(payload, field="manifest") + if set(root) != {"schema_version", "organization", "repositories"}: + raise ManifestError("manifest has an unexpected key set") + if ( + type(root["schema_version"]) is not int + or root["schema_version"] != 1 + or root["organization"] != ORGANIZATION + ): + raise ManifestError("manifest schema or organization is unsupported") + repositories = _require_exact_dict(root["repositories"], field="repositories") + if not repositories: + raise ManifestError("manifest must declare at least one repository") + + validated: dict[str, dict[str, Any]] = {} + casing_by_identity: dict[str, str] = {} + for name, value in repositories.items(): + state = _validate_repository(name, value) + identity = name.casefold() + prior = casing_by_identity.get(identity) + if prior is not None and prior != name: + raise ManifestError( + f"repository casing collision: {prior} and {name} identify the same GitHub repository" + ) + casing_by_identity[identity] = name + validated[name] = state + return validated + + +def _gh_api( + method: str, + endpoint: str, + *, + fields: dict[str, Any] | None = None, + body: Any = None, +) -> str: + """Call GitHub CLI with fixed API endpoints and content-bounded arguments.""" + + command = ["gh", "api", "--method", method, endpoint] + if body is not None: + command.extend(["--input", "-"]) + for key, value in (fields or {}).items(): + command.extend(["--field", f"{key}={value}"]) + completed = subprocess.run( + command, + check=False, + input=None if body is None else json.dumps(body, separators=(",", ":")), + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode != 0: + raise RuntimeError(f"GitHub API request failed for {endpoint}") + return completed.stdout + + +def _pages_exists(repository: str) -> bool: + """Return whether GitHub Pages already exists for the repository.""" + + command = ["gh", "api", f"repos/{ORGANIZATION}/{repository}/pages"] + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode == 0: + return True + combined = f"{completed.stdout}\n{completed.stderr}" + if "HTTP 404" in combined or "Not Found" in combined: + return False + raise RuntimeError(f"GitHub Pages state could not be resolved for {repository}") + + +def _pages_configuration(repository: str) -> dict[str, Any]: + """Return the current Pages configuration after existence has been established.""" + + payload = json.loads(_gh_api("GET", f"repos/{ORGANIZATION}/{repository}/pages")) + return _require_exact_dict(payload, field=f"Pages configuration for {repository}") + + +def _pages_configuration_matches(current: dict[str, Any], default_branch: str) -> bool: + """Return whether Pages already serves the desired legacy /docs source.""" + + source = current.get("source") + if type(source) is not dict: + return False + return ( + source.get("branch") == default_branch + and source.get("path") == "/docs" + and current.get("build_type") in (None, "legacy") + ) + + +def _pages_url_is_expected(url: Any) -> bool: + """Return whether a URL is confined to the organization-owned Pages origin.""" + + return type(url) is str and ( + url == PAGES_BASE_URL or url.startswith(f"{PAGES_BASE_URL}/") + ) + + +def _pages_publication_ready(repository: str, current: dict[str, Any]) -> None: + """Require a built Pages site whose published HTTPS URL is actually reachable.""" + + if current.get("status") != "built": + raise RuntimeError(f"GitHub Pages is not built for {repository}") + html_url = current.get("html_url") + if not _pages_url_is_expected(html_url): + raise RuntimeError(f"GitHub Pages URL is invalid for {repository}") + request = Request( + html_url, + headers={"User-Agent": "ContextualWisdomLab-repository-metadata-reconcile"}, + ) + opener = build_opener(_NoPagesRedirects()) + try: + with opener.open(request, timeout=10) as response: + if not response.read(1): + raise RuntimeError(f"GitHub Pages returned empty content for {repository}") + except (URLError, TimeoutError, OSError) as exc: + raise RuntimeError(f"GitHub Pages is not reachable for {repository}") from exc + + +def _docs_index_exists(repository: str, default_branch: str) -> bool: + """Return whether the reviewed default branch contains docs/index.md.""" + + endpoint = ( + f"repos/{ORGANIZATION}/{repository}/contents/docs/index.md?ref={default_branch}" + ) + command = ["gh", "api", endpoint] + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode == 0: + return True + combined = f"{completed.stdout}\n{completed.stderr}" + if "HTTP 404" in combined or "Not Found" in combined: + return False + raise RuntimeError(f"Pages source state could not be resolved for {repository}") + + +def _deepwiki_badge_linked(readme: str, repository: str) -> bool: + """Return whether one badge image links to the exact repository DeepWiki target.""" + + image = re.escape("https://deepwiki.com/badge.svg") + target = re.escape(f"https://deepwiki.com/{ORGANIZATION}/{repository}") + markdown = re.compile(rf"\[!\[[^\]]*\]\({image}\)\]\({target}\)") + html = re.compile( + rf").)*\bhref=[\"'](?-i:{target})[\"'](?:(?!>).)*>" + rf"(?:(?!).)*?" + rf").)*\bsrc=[\"'](?-i:{image})[\"'](?:(?!>).)*>" + rf"(?:(?!).)*?", + re.IGNORECASE | re.DOTALL, + ) + return bool(markdown.search(readme) or html.search(readme)) + + +def _deepwiki_badge_exists(repository: str, default_branch: str) -> bool: + """Return whether the default-branch README carries the exact linked badge.""" + + endpoint = f"repos/{ORGANIZATION}/{repository}/contents/README.md?ref={default_branch}" + command = [ + "gh", + "api", + "-H", + "Accept: application/vnd.github.raw+json", + endpoint, + ] + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + if completed.returncode != 0: + combined = f"{completed.stdout}\n{completed.stderr}" + if "HTTP 404" in combined or "Not Found" in combined: + return False + raise RuntimeError(f"README state could not be resolved for {repository}") + return _deepwiki_badge_linked(completed.stdout, repository) + + +def reconcile_repository(repository: str, desired: dict[str, Any]) -> None: + """Apply one validated desired-state record through least-privilege GitHub APIs.""" + + repository_payload = json.loads( + _gh_api("GET", f"repos/{ORGANIZATION}/{repository}") + ) + default_branch = repository_payload.get("default_branch") + if type(default_branch) is not str or not default_branch: + raise RuntimeError(f"default branch could not be resolved for {repository}") + + badge_exists = _deepwiki_badge_exists(repository, default_branch) + if desired["deepwiki"] and not badge_exists: + raise RuntimeError( + f"DeepWiki badge requested for {repository} but the exact badge is not on {default_branch}" + ) + if not desired["deepwiki"] and badge_exists: + raise RuntimeError( + f"DeepWiki badge is disabled for {repository} but the exact badge is still on {default_branch}" + ) + if desired["pages"] and not _docs_index_exists(repository, default_branch): + raise RuntimeError( + f"Pages requested for {repository} but docs/index.md is not on {default_branch}" + ) + + if repository_payload.get("description") != desired["description"]: + _gh_api( + "PATCH", + f"repos/{ORGANIZATION}/{repository}", + body={"description": desired["description"]}, + ) + + current_topics = json.loads( + _gh_api("GET", f"repos/{ORGANIZATION}/{repository}/topics") + ).get("names", []) + if set(current_topics) != set(desired["topics"]): + _gh_api( + "PUT", + f"repos/{ORGANIZATION}/{repository}/topics", + body={"names": desired["topics"]}, + ) + + pages_exists = _pages_exists(repository) + if desired["pages"]: + pages_body = { + "build_type": "legacy", + "source": {"branch": default_branch, "path": "/docs"}, + } + if not pages_exists: + _gh_api( + "POST", + f"repos/{ORGANIZATION}/{repository}/pages", + body=pages_body, + ) + elif not _pages_configuration_matches( + _pages_configuration(repository), default_branch + ): + _gh_api( + "PUT", + f"repos/{ORGANIZATION}/{repository}/pages", + body=pages_body, + ) + elif pages_exists: + _gh_api("DELETE", f"repos/{ORGANIZATION}/{repository}/pages") + + +def verify_repository(repository: str, desired: dict[str, Any]) -> None: + """Re-read live public state and fail unless it exactly matches desired state.""" + + repository_payload = json.loads( + _gh_api("GET", f"repos/{ORGANIZATION}/{repository}") + ) + default_branch = repository_payload.get("default_branch") + if type(default_branch) is not str or not default_branch: + raise RuntimeError(f"default branch could not be resolved for {repository}") + if repository_payload.get("description") != desired["description"]: + raise RuntimeError(f"description did not converge for {repository}") + + current_topics = json.loads( + _gh_api("GET", f"repos/{ORGANIZATION}/{repository}/topics") + ).get("names", []) + if set(current_topics) != set(desired["topics"]): + raise RuntimeError(f"topics did not converge for {repository}") + + badge_exists = _deepwiki_badge_exists(repository, default_branch) + if badge_exists != desired["deepwiki"]: + raise RuntimeError(f"DeepWiki state did not converge for {repository}") + if desired["pages"] and not _docs_index_exists(repository, default_branch): + raise RuntimeError(f"Pages source did not converge for {repository}") + + pages_exists = _pages_exists(repository) + if desired["pages"]: + if not pages_exists: + raise RuntimeError(f"GitHub Pages was not published for {repository}") + current_pages = _pages_configuration(repository) + if not _pages_configuration_matches(current_pages, default_branch): + raise RuntimeError(f"GitHub Pages configuration did not converge for {repository}") + _pages_publication_ready(repository, current_pages) + elif pages_exists: + raise RuntimeError(f"GitHub Pages remained published for {repository}") + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments for validation, apply, or verification mode.""" + + parser = argparse.ArgumentParser() + parser.add_argument("--manifest", type=Path, required=True) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--validate-only", action="store_true") + mode.add_argument("--verify-only", action="store_true") + parser.add_argument("--repository", action="append", default=[]) + return parser.parse_args() + + +def _select_repositories( + requested: list[str], repositories: dict[str, dict[str, Any]] +) -> list[str]: + """Canonicalize case-insensitive GitHub identities to reviewed repository casing.""" + + if not requested: + return list(repositories) + canonical_by_identity = {name.casefold(): name for name in repositories} + selected: list[str] = [] + seen: set[str] = set() + unknown: list[str] = [] + for candidate in requested: + identity = candidate.casefold() + canonical = canonical_by_identity.get(identity) + if canonical is None: + unknown.append(candidate) + continue + if identity not in seen: + seen.add(identity) + selected.append(canonical) + if unknown: + raise ManifestError(f"undeclared repositories requested: {', '.join(sorted(unknown))}") + return selected + + +def main() -> int: + """Validate, reconcile, or verify every independent repository possible.""" + + args = parse_args() + repositories = load_manifest(args.manifest) + if args.validate_only: + return 0 + if not os.environ.get("GH_TOKEN"): + raise RuntimeError("GH_TOKEN is required outside validation mode") + selected = _select_repositories(args.repository, repositories) + operation = verify_repository if getattr(args, "verify_only", False) else reconcile_repository + + failures: list[str] = [] + for repository in selected: + try: + operation(repository, repositories[repository]) + except ( + ManifestError, + RuntimeError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: + failures.append(f"{repository}: {exc}") + print( + f"repository metadata reconciliation failed for {repository}: {exc}", + file=sys.stderr, + ) + if failures: + raise RuntimeError("metadata reconciliation failed: " + "; ".join(failures)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index d5db849145..42e640bc21 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -200,7 +200,7 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "branches: [main, develop, master]" "strix workflow scans GitHub Flow and Git Flow protected branches" assert_file_contains "$workflow_file" "pull_request_target:" "strix workflow uses trusted PR trigger" assert_file_contains "$workflow_file" "group: >-" "strix workflow defines an explicit concurrency group" - assert_file_contains "$workflow_file" "cancel-superseded-pr-runs:" "strix workflow runs superseded-head cleanup outside the provider scan queue" + assert_file_not_contains "$workflow_file" "cancel-superseded-pr-runs:" "strix workflow does not depend on a runner-backed superseded-head cleanup job" assert_file_not_contains "$workflow_file" "format('closed-pr-{0}-{1}'" "strix cleanup does not need a second concurrency queue" assert_file_contains "$workflow_file" "format('{0}-{1}', github.event_name, github.event.client_payload.target_repository ||" "strix workflow scopes active evidence per repository and event class" assert_file_contains "$workflow_file" "format('{0}-{1}-{2}', github.event_name, github.repository, github.ref)" "strix workflow keeps protected-branch push evidence in ref-specific queues" @@ -212,7 +212,10 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "cancel-in-progress: false" "strix workflow does not cancel an in-progress provider scan" assert_file_not_contains "$workflow_file" "queue: max" "strix workflow uses only supported GitHub concurrency keys" assert_file_contains "$workflow_file" "format('{0}-{1}', github.event_name," "strix workflow isolates repository_dispatch evidence from pull-request evidence" - assert_file_contains "$workflow_file" "re-dispatches exact-head evidence" "strix workflow documents current-head queue recovery" + assert_file_contains "$workflow_file" "Validate live pull request before Strix setup" "strix workflow validates live PR identity before setup" + assert_file_contains "$workflow_file" "Revalidate live pull request before provider execution" "strix workflow revalidates live PR identity before provider work" + assert_file_contains "$workflow_file" "Revalidate live pull request before evidence publication" "strix workflow revalidates live PR identity before publication" + assert_file_contains "$workflow_file" "steps.live_publication.outputs.current == 'true'" "strix workflow gates publication on current live-head identity" assert_file_contains "$workflow_file" "refs/pull//head has already advanced before this queued run starts" "strix workflow documents stale scan queue avoidance" status_token_count="$(grep -c '^[[:space:]]*GITHUB_STATUS_TOKEN:' "$workflow_file")" assert_equals "1" "$status_token_count" "strix workflow defines GITHUB_STATUS_TOKEN once so GitHub can parse repository_dispatch" @@ -299,7 +302,7 @@ assert_strix_workflow_pr_trigger_hardened() { assert_file_contains "$workflow_file" "CONTEXTUAL_ORCHESTRATOR_TOKEN" "strix workflow uses the sidecar token" assert_file_not_contains "$workflow_file" "timeout-minutes: 200" "strix workflow job must not cap model inference" assert_file_not_contains "$workflow_file" "timeout-minutes: 170" "strix scan step must not cap model inference" - assert_file_contains "$workflow_file" 'export LLM_TIMEOUT=0' "strix disables the model client inference timeout" + assert_file_contains "$workflow_file" 'export LLM_TIMEOUT=300' "strix preserves the current-main bounded model preflight timeout" assert_file_contains "$workflow_file" 'export STRIX_MEMORY_COMPRESSOR_TIMEOUT=0' "strix disables the memory-compressor inference timeout" assert_file_contains "$workflow_file" 'export STRIX_PROCESS_TIMEOUT_SECONDS=0' "strix disables the scanner process timeout" assert_file_contains "$workflow_file" 'export STRIX_TOTAL_TIMEOUT_SECONDS=0' "strix disables the total scanner timeout" diff --git a/tests/test_install_python_requirements_for_coverage.py b/tests/test_install_python_requirements_for_coverage.py deleted file mode 100644 index 9d75bf9423..0000000000 --- a/tests/test_install_python_requirements_for_coverage.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Tests for coverage dependency-install policy logging.""" - -from __future__ import annotations - -import importlib.util -import pathlib -import runpy -import sys - - -MODULE_PATH = ( - pathlib.Path(__file__).resolve().parents[1] - / "scripts" - / "ci" - / "install_python_requirements_for_coverage.py" -) - - -def load_module(): - """Load the helper from its script path.""" - spec = importlib.util.spec_from_file_location( - "install_python_requirements_for_coverage", MODULE_PATH - ) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_missing_requirements_file_fails_with_visible_reason(tmp_path, capsys): - """Missing input fails closed before any installer is invoked.""" - module = load_module() - - rc = module.main([str(tmp_path / "missing.txt")]) - - assert rc == 2 - assert "requirements file not found" in capsys.readouterr().err - - -def test_blank_and_comment_only_requirements_are_hash_safe(tmp_path): - """Empty requirements files do not need network dependency resolution.""" - module = load_module() - requirements = tmp_path / "requirements.txt" - requirements.write_text("\n# comment only\n", encoding="utf-8") - - assert module._requirement_lines(requirements) == [] - assert module._has_hash_pins(requirements) is True - - -def test_hash_pinned_requirements_use_pip_require_hashes(tmp_path, monkeypatch): - """Hash-pinned target requirements install with pip hash verification.""" - module = load_module() - requirements = tmp_path / "requirements.txt" - requirements.write_text( - "demo==1.0 --hash=sha256:" + ("a" * 64) + "\n", - encoding="utf-8", - ) - calls: list[tuple[list[str], pathlib.Path]] = [] - - def fake_run(command, cwd): - calls.append((command, cwd)) - return 0 - - monkeypatch.setattr(module, "_run", fake_run) - - rc = module.main([str(requirements)]) - - assert rc == 0 - command, cwd = calls[0] - assert command[:5] == [ - sys.executable, - "-m", - "pip", - "install", - "--disable-pip-version-check", - ] - assert "--require-hashes" in command - assert cwd == tmp_path - - -def test_unhashed_requirements_use_uv_with_warning(tmp_path, monkeypatch, capsys): - """Unhashed target requirements are visibly marked coverage-only.""" - module = load_module() - requirements = tmp_path / "requirements.txt" - requirements.write_text("demo==1.0\n", encoding="utf-8") - calls: list[tuple[list[str], pathlib.Path]] = [] - - monkeypatch.setattr(module.shutil, "which", lambda name: "/usr/bin/uv") - - def fake_run(command, cwd): - calls.append((command, cwd)) - return 0 - - monkeypatch.setattr(module, "_run", fake_run) - - rc = module.main([str(requirements)]) - - assert rc == 0 - assert calls == [ - ( - ["/usr/bin/uv", "pip", "install", "--system", "-r", str(requirements)], - tmp_path, - ) - ] - assert "not hash-pinned" in capsys.readouterr().out - - -def test_unhashed_requirements_fail_when_uv_is_unavailable(tmp_path, monkeypatch, capsys): - """Unhashed target requirements fail closed when uv cannot sandbox install.""" - module = load_module() - requirements = tmp_path / "requirements.txt" - requirements.write_text("demo==1.0\n", encoding="utf-8") - monkeypatch.setattr(module.shutil, "which", lambda name: None) - - rc = module.main([str(requirements)]) - - assert rc == 1 - assert "uv is unavailable" in capsys.readouterr().err - - -def test_run_returns_subprocess_status(tmp_path): - """Command execution returns the subprocess exit code.""" - module = load_module() - - rc = module._run([sys.executable, "-c", "raise SystemExit(7)"], tmp_path) - - assert rc == 7 - - -def test_script_entrypoint_exits_through_main(tmp_path, monkeypatch): - """The script entry point delegates to main and exits with its return code.""" - missing = tmp_path / "missing.txt" - monkeypatch.setattr(sys, "argv", [str(MODULE_PATH), str(missing)]) - - try: - runpy.run_path(str(MODULE_PATH), run_name="__main__") - except SystemExit as exc: - assert exc.code == 2 - else: - raise AssertionError("expected SystemExit") diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index 5355a8ca89..3f6116caf4 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -172,8 +172,13 @@ def test_noema_review_credentials_and_llm_use_orchestrator_free() -> None: assert "OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}" in workflow assert "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}" in workflow assert 'export NOEMA_LLM_MODEL="orchestrator/free"' in workflow - assert "python3 -m scripts.ci.noema_review_gate" in workflow - assert "python3 scripts/ci/noema_review_gate.py" not in workflow + prepare = workflow_step(workflow, "Prepare Noema model verdict") + publish = workflow_step(workflow, "Publish prepared Noema verdict on the exact live head") + assert '.github/actions/noema-review/two_phase.py' in prepare + assert '--prepare-verdict-file "$verdict_file"' in prepare + assert '.github/actions/noema-review/two_phase.py' in publish + assert '--publish-verdict-file "$verdict_file"' in publish + assert "python3 -m scripts.ci.noema_review_gate" not in workflow assert ( "contextual-orchestrator review sidecar must be provisioned before Noema LLM review." in workflow @@ -339,7 +344,7 @@ def test_strix_gateway_default_and_noema_sidecar_fail_closed(tmp_path: Path) -> noema_script = textwrap.dedent( workflow_step( workflow_text("noema-review.yml"), - "Run Noema LLM review and submit verdict", + "Prepare Noema model verdict", ).split(" run: |\n", 1)[1] ) noema_env = { diff --git a/tests/test_noema_reviewer_token_lifetime.py b/tests/test_noema_reviewer_token_lifetime.py new file mode 100644 index 0000000000..8057a23435 --- /dev/null +++ b/tests/test_noema_reviewer_token_lifetime.py @@ -0,0 +1,65 @@ +"""Regression contract for Noema reviewer credential lifetime.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "noema-review.yml" +APP_TOKEN_ACTION = ( + "uses: actions/create-github-app-token@" + "bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0" +) + + +def _step_block(text: str, name: str) -> str: + """Return one exact named workflow step without borrowing sibling evidence.""" + marker = f" - name: {name}\n" + start = text.index(marker) + next_step = text.find("\n - name: ", start + len(marker)) + return text[start:] if next_step < 0 else text[start:next_step] + + +def test_noema_remints_repository_scoped_app_token_after_model_before_publication() -> None: + """A long model call must not publish with its predecessor App token.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + prepare = _step_block(workflow, "Prepare Noema model verdict") + refresh = _step_block(workflow, "Refresh repository-scoped Noema GitHub App token for publication") + publish = _step_block(workflow, "Publish prepared Noema verdict on the exact live head") + + assert APP_TOKEN_ACTION in refresh + assert "--prepare-verdict-file" in prepare + assert "--publish-verdict-file" in publish + assert '--expected-head "$EXPECTED_HEAD_SHA"' in prepare + assert '--expected-head "$EXPECTED_HEAD_SHA"' in publish + assert 'export NOEMA_LLM_MODEL="orchestrator/free"' in prepare + assert "steps.noema_prepare.outputs.prepared == 'true'" in refresh + assert "steps.noema_credential.outputs.source == 'github-app'" in refresh + assert "steps.noema_prepare.outputs.prepared == 'true'" in publish + + +def test_publication_step_uses_fresh_app_token_without_authority_fallback() -> None: + """Publication selects the refreshed App token and fails closed for unknown sources.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + refresh = _step_block(workflow, "Refresh repository-scoped Noema GitHub App token for publication") + publish = _step_block(workflow, "Publish prepared Noema verdict on the exact live head") + + assert "owner: ContextualWisdomLab" in refresh + assert "repositories: ${{ steps.noema_credential.outputs.repository }}" in refresh + assert "permission-pull-requests: write" in refresh + assert "permission-contents: read" in refresh + assert "permission-actions: read" in refresh + assert "steps.noema_github_app_publication_token.outputs.token" in publish + assert "steps.noema_github_app_token.outputs.token" not in publish + assert "secrets.NOEMA_REVIEW_TOKEN" in publish + assert "steps.noema_oidc_token.outputs.token" in publish + assert "github.token" not in publish + assert "refusing any GITHUB_TOKEN or author fallback" in publish + + +def test_prepare_and_publish_are_the_only_model_verdict_execution_path() -> None: + """The old single-process review path must not survive beside the handoff.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + assert "Run Noema LLM review and submit verdict" not in workflow + assert "python3 -m scripts.ci.noema_review_gate" not in workflow + assert workflow.count("--prepare-verdict-file") == 1 + assert workflow.count("--publish-verdict-file") == 1 diff --git a/tests/test_noema_two_phase_handoff.py b/tests/test_noema_two_phase_handoff.py new file mode 100644 index 0000000000..992522be7b --- /dev/null +++ b/tests/test_noema_two_phase_handoff.py @@ -0,0 +1,193 @@ +"""Executable regressions for the Noema two-phase reviewer handoff.""" + +from __future__ import annotations + +import importlib.util +import os +from pathlib import Path +from types import ModuleType + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / ".github" / "actions" / "noema-review" / "two_phase.py" +HEAD = "a" * 40 +BASE = "b" * 40 + + +def _load_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("noema_two_phase_under_test", MODULE_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 + + +def _patch_live_gate(monkeypatch: pytest.MonkeyPatch, module: ModuleType) -> None: + monkeypatch.setattr( + module.gate, + "fetch_pr", + lambda _repo, _number: { + "isDraft": False, + "headRefOid": HEAD, + "baseRefOid": BASE, + }, + ) + monkeypatch.setattr(module.gate, "require_expected_head", lambda _pr, _head: None) + monkeypatch.setattr(module.gate, "current_actor", lambda: "cwl-noema-review[bot]") + monkeypatch.setattr(module.gate, "PRIMARY_REVIEW_AUTHORS", frozenset({"seonghobae"})) + monkeypatch.setattr(module.gate, "existing_noema_review", lambda _pr, _actor: False) + + +def test_prepare_seals_validated_verdict_without_publishing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Preparation performs model work but cannot submit GitHub review evidence.""" + module = _load_module() + _patch_live_gate(monkeypatch, module) + monkeypatch.setattr(module.gate, "fetch_diff", lambda _repo, _number: ("diff", False)) + monkeypatch.setattr(module.gate, "fetch_changed_files", lambda _repo, _number: [("src/a.py", "MODIFIED")]) + monkeypatch.setattr(module.gate, "build_review_context", lambda *_args: "context") + verdict = {"decision": "approve", "summary": "bounded"} + monkeypatch.setattr(module.gate, "call_llm", lambda *_args: verdict) + monkeypatch.setattr(module.gate, "submit_review", lambda *_args: pytest.fail("preparation must never publish")) + envelope = tmp_path / "verdict.json" + + assert module.prepare_verdict("ContextualWisdomLab/example", 7, HEAD, envelope) == 0 + payload = module._read_envelope(envelope) + assert payload["verdict"] == verdict + assert payload["expected_base"] == BASE + + +def test_publish_refetches_exact_head_and_base_with_fresh_actor_and_removes_envelope(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Publication rebinds repository/head/base/actor and consumes the private handoff.""" + module = _load_module() + _patch_live_gate(monkeypatch, module) + envelope = tmp_path / "verdict.json" + verdict = {"decision": "approve", "summary": "bounded"} + module._write_envelope(envelope, { + "schema_version": module.ENVELOPE_SCHEMA_VERSION, + "repository": "ContextualWisdomLab/example", + "pull_request_number": 7, + "expected_head": HEAD, + "expected_base": BASE, + "verdict": verdict, + }) + submitted: list[tuple[object, ...]] = [] + monkeypatch.setattr(module.gate, "submit_review", lambda *args: submitted.append(args)) + + assert module.publish_verdict("ContextualWisdomLab/example", 7, HEAD, envelope) == 0 + assert len(submitted) == 1 + assert submitted[0][0:2] == ("ContextualWisdomLab/example", 7) + assert submitted[0][3] == "cwl-noema-review[bot]" + assert submitted[0][4] == verdict + assert not envelope.exists() + + +def test_publish_rejects_stale_head_and_never_submits(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A moved head invalidates predecessor model evidence before publication.""" + module = _load_module() + monkeypatch.setattr( + module.gate, + "fetch_pr", + lambda _repo, _number: { + "isDraft": False, + "headRefOid": "c" * 40, + "baseRefOid": BASE, + }, + ) + + def stale(_pr: object, _head: str) -> None: + raise RuntimeError("stale") + + monkeypatch.setattr(module.gate, "require_expected_head", stale) + monkeypatch.setattr(module.gate, "submit_review", lambda *_args: pytest.fail("stale evidence must not publish")) + envelope = tmp_path / "verdict.json" + module._write_envelope(envelope, { + "schema_version": module.ENVELOPE_SCHEMA_VERSION, + "repository": "ContextualWisdomLab/example", + "pull_request_number": 7, + "expected_head": HEAD, + "expected_base": BASE, + "verdict": {"decision": "approve"}, + }) + + assert module.publish_verdict("ContextualWisdomLab/example", 7, HEAD, envelope) == 0 + assert not envelope.exists() + + +def test_publish_rejects_base_drift_with_unchanged_head(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A moved base invalidates the prepared diff/context even when the head is unchanged.""" + module = _load_module() + _patch_live_gate(monkeypatch, module) + monkeypatch.setattr( + module.gate, + "fetch_pr", + lambda _repo, _number: { + "isDraft": False, + "headRefOid": HEAD, + "baseRefOid": "c" * 40, + }, + ) + envelope = tmp_path / "verdict.json" + module._write_envelope(envelope, { + "schema_version": module.ENVELOPE_SCHEMA_VERSION, + "repository": "ContextualWisdomLab/example", + "pull_request_number": 7, + "expected_head": HEAD, + "expected_base": BASE, + "verdict": {"decision": "approve", "summary": "stale base"}, + }) + monkeypatch.setattr(module.gate, "submit_review", lambda *_args: pytest.fail("base-drifted evidence must not publish")) + + assert module.publish_verdict("ContextualWisdomLab/example", 7, HEAD, envelope) == 0 + assert not envelope.exists() + + +def test_prepare_skip_creates_no_publishable_envelope(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Draft skip semantics stay non-failing and cannot fabricate evidence.""" + module = _load_module() + monkeypatch.setattr( + module.gate, + "fetch_pr", + lambda _repo, _number: { + "isDraft": True, + "headRefOid": HEAD, + "baseRefOid": BASE, + }, + ) + monkeypatch.setattr(module.gate, "require_expected_head", lambda _pr, _head: None) + monkeypatch.setattr(module.gate, "current_actor", lambda: "cwl-noema-review[bot]") + monkeypatch.setattr(module.gate, "PRIMARY_REVIEW_AUTHORS", frozenset({"seonghobae"})) + monkeypatch.setattr(module.gate, "existing_noema_review", lambda _pr, _actor: False) + monkeypatch.setattr(module.gate, "call_llm", lambda *_args: pytest.fail("draft must not call the model")) + envelope = tmp_path / "verdict.json" + + assert module.prepare_verdict("ContextualWisdomLab/example", 7, HEAD, envelope) == 0 + assert not envelope.exists() + + +def test_publish_cleans_untrusted_envelope_even_when_read_validation_fails(tmp_path: Path) -> None: + """Malformed handoff state cannot linger after a failed publication attempt.""" + module = _load_module() + envelope = tmp_path / "verdict.json" + envelope.write_text("{}\n", encoding="utf-8") + os.chmod(envelope, 0o644) + + with pytest.raises(RuntimeError, match="permissions"): + module.publish_verdict("ContextualWisdomLab/example", 7, HEAD, envelope) + assert not envelope.exists() + + +def test_reader_rejects_hardlinked_aliases(tmp_path: Path) -> None: + """A caller-owned alias cannot mutate the supposedly private handoff file.""" + module = _load_module() + envelope = tmp_path / "verdict.json" + alias = tmp_path / "alias.json" + module._write_envelope(envelope, {"schema_version": module.ENVELOPE_SCHEMA_VERSION}) + os.link(envelope, alias) + try: + with pytest.raises(RuntimeError, match="single-link"): + module._read_envelope(envelope) + finally: + envelope.unlink(missing_ok=True) + alias.unlink(missing_ok=True) diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 55513c16c7..3e485f8241 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -469,12 +469,12 @@ def test_opencode_ignores_superseded_cancelled_rollup_checks(): def test_opencode_target_coverage_materializes_only_after_authorized_dispatch(): """Keep PR-controlled test execution off the pull_request_target path.""" workflow = Path(".github/workflows/opencode-review-dispatch.yml").read_text(encoding="utf-8") - assert "required-workflow-bootstrap:" in workflow - assert "OpenCode repository-dispatch review run materialized." in workflow - bootstrap_start = workflow.index(" required-workflow-bootstrap:\n") - bootstrap_end = workflow.index("\n validate-pr-metadata:", bootstrap_start) - bootstrap_job = workflow[bootstrap_start:bootstrap_end] - assert "\n if:" not in bootstrap_job + # required-workflow-bootstrap is the trusted-source-resolution sentinel needed + # only where the org ruleset targets a pull_request_target entrypoint + # (opencode-review.yml). This repository_dispatch-only workflow is not itself + # a required-workflow path, so it must not carry a copy-pasted, need-less + # orphan of that job. + assert "required-workflow-bootstrap:" not in workflow assert ( "github.event.pull_request.head.repo.full_name == github.repository" not in workflow diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index d10b2f1e26..750894fe44 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -19,7 +19,7 @@ DOCTORING_RECORD = Path("docs/doctoring/hourly-nvidia-nim-autofix.md") CHANGELOG = Path("CHANGELOG.md") REVIEW_DISPATCH_WORKFLOW = Path(".github/workflows/opencode-review-dispatch.yml") -REVIEW_DISPATCH_BLOB_SHA = "cc0b84dff19195a7e209e9f78cd5ee80bfc58d53" +REVIEW_DISPATCH_BLOB_SHA = "bb5d439c3fc2fc7b5fcd38533d38f96e1170cd2e" def _workflow_text(path: Path) -> str: diff --git a/tests/test_repository_label_convergence.py b/tests/test_repository_label_convergence.py new file mode 100644 index 0000000000..0275f6cc46 --- /dev/null +++ b/tests/test_repository_label_convergence.py @@ -0,0 +1,60 @@ +"""Focused convergence regressions for repository label reconciliation.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_labels.py" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_labels", SCRIPT) +assert SPEC and SPEC.loader +LABELS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LABELS) + + +def test_existing_desired_label_does_not_get_readded_while_obsolete_type_is_removed( + monkeypatch, +) -> None: + """A mixed managed state removes only the obsolete label.""" + + calls: list[tuple[str, str, object, bool]] = [] + reads = iter( + [ + json.dumps( + { + "labels": [ + {"name": "documentation"}, + {"name": "bug"}, + {"name": "status: needs-review"}, + ] + } + ), + json.dumps( + { + "labels": [ + {"name": "documentation"}, + {"name": "status: needs-review"}, + ] + } + ), + ] + ) + + def gh_api(method, endpoint, body=None, allow_not_found=False): + calls.append((method, endpoint, body, allow_not_found)) + if method == "GET": + return next(reads) + return "" + + monkeypatch.setattr(LABELS, "_gh_api", gh_api) + + LABELS.reconcile_assignment( + {"repository": "Repo", "issue": 1, "type": "documentation"}, + {"bug": "bug", "documentation": "documentation"}, + ) + + assert [call[0] for call in calls] == ["GET", "DELETE", "GET"] + assert calls[1][1].endswith("/labels/bug") diff --git a/tests/test_repository_label_identity.py b/tests/test_repository_label_identity.py new file mode 100644 index 0000000000..aadc8ca7ea --- /dev/null +++ b/tests/test_repository_label_identity.py @@ -0,0 +1,98 @@ +"""Repository identity regressions for label desired state.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_labels.py" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_labels", SCRIPT) +assert SPEC and SPEC.loader +LABELS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LABELS) + + +def test_taxonomy_rejects_case_only_repository_collisions(tmp_path: Path) -> None: + """Assignments cannot spell one GitHub repository with conflicting casing.""" + + path = tmp_path / "taxonomy.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [ + {"repository": "Repo", "issue": 1, "type": "feature"}, + {"repository": "repo", "issue": 2, "type": "feature"}, + ], + } + ), + encoding="utf-8", + ) + + with pytest.raises(LABELS.TaxonomyError, match="casing collision"): + LABELS.load_taxonomy(path) + + +def test_taxonomy_rejects_case_only_managed_label_collisions(tmp_path: Path) -> None: + """Managed label identities cannot differ only by GitHub-insensitive casing.""" + + path = tmp_path / "taxonomy.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "type": {"feature": "Enhancement", "bug": "enhancement"}, + "assignments": [], + } + ), + encoding="utf-8", + ) + + with pytest.raises(LABELS.TaxonomyError, match="unique ignoring case"): + LABELS.load_taxonomy(path) + + +def test_label_filters_normalize_case_and_reject_unknown_repositories() -> None: + """Narrow reconciliation filters use GitHub identity but keep reviewed casing.""" + + assignments = [ + {"repository": "Repo", "issue": 1, "type": "feature"}, + {"repository": "OtherRepo", "issue": 2, "type": "feature"}, + ] + + assert LABELS._select_repository_identities([], assignments) == set() + assert LABELS._select_repository_identities( + ["repo", "REPO", "OtherRepo"], assignments + ) == {"repo", "otherrepo"} + with pytest.raises(LABELS.TaxonomyError, match="undeclared"): + LABELS._select_repository_identities(["missing"], assignments) + + +def test_managed_label_comparison_is_case_insensitive(monkeypatch) -> None: + """Existing differently cased managed labels do not churn on every run.""" + + calls = [] + + def gh_api(method, endpoint, body=None, allow_not_found=False): + calls.append((method, endpoint, body, allow_not_found)) + return json.dumps( + {"labels": [{"name": "DOCUMENTATION"}, {"name": "status: ready"}]} + ) + + monkeypatch.setattr(LABELS, "_gh_api", gh_api) + item = {"repository": "Repo", "issue": 1, "type": "documentation"} + mappings = {"bug": "Bug", "documentation": "documentation"} + + LABELS.reconcile_assignment(item, mappings) + LABELS.verify_assignment(item, mappings) + + assert [call[0] for call in calls] == ["GET", "GET"] + assert LABELS._label_names( + {"labels": ["Bug", {"name": "BUG"}, {"name": "Other"}]} + ) == ["Bug", "Other"] diff --git a/tests/test_repository_label_live_verification.py b/tests/test_repository_label_live_verification.py new file mode 100644 index 0000000000..d3f8bff74a --- /dev/null +++ b/tests/test_repository_label_live_verification.py @@ -0,0 +1,97 @@ +"""Live post-apply verification contracts for reviewed repository labels.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_labels.py" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_labels", SCRIPT) +assert SPEC and SPEC.loader +LABELS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LABELS) + + +def assignment() -> dict[str, object]: + """Return one reviewed label assignment.""" + + return {"repository": "Repo", "issue": 1, "type": "documentation"} + + +def type_map() -> dict[str, str]: + """Return a minimal managed label universe.""" + + return {"bug": "bug", "documentation": "documentation"} + + +def test_verify_assignment_accepts_only_exact_managed_postcondition(monkeypatch) -> None: + """Unmanaged labels survive while the one desired managed label must be exact.""" + + monkeypatch.setattr( + LABELS, + "_gh_api", + lambda *args, **kwargs: json.dumps( + { + "labels": [ + {"name": "status: needs-review"}, + {"name": "documentation"}, + ] + } + ), + ) + LABELS.verify_assignment(assignment(), type_map()) + + monkeypatch.setattr( + LABELS, + "_gh_api", + lambda *args, **kwargs: json.dumps({"labels": [{"name": "bug"}]}), + ) + with pytest.raises(RuntimeError, match="managed labels did not converge"): + LABELS.verify_assignment(assignment(), type_map()) + + +def test_main_verify_only_uses_read_only_verifier(monkeypatch, tmp_path: Path) -> None: + """Verify-only mode checks assignments without entering mutation logic.""" + + taxonomy = tmp_path / "taxonomy.json" + taxonomy.write_text( + json.dumps( + { + "schema_version": 1, + "type": {"documentation": "documentation"}, + "assignments": [assignment()], + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("GH_TOKEN", "token") + monkeypatch.setattr( + LABELS, + "parse_args", + lambda: argparse.Namespace( + taxonomy=taxonomy, + validate_only=False, + verify_only=True, + repository=[], + ), + ) + seen = [] + monkeypatch.setattr( + LABELS, + "verify_assignment", + lambda item, mappings: seen.append(item["repository"]), + ) + monkeypatch.setattr( + LABELS, + "reconcile_assignment", + lambda *args: pytest.fail("mutation path used in verify-only mode"), + ) + + assert LABELS.main() == 0 + assert seen == ["Repo"] diff --git a/tests/test_repository_label_reconciliation.py b/tests/test_repository_label_reconciliation.py new file mode 100644 index 0000000000..d66e45bdfe --- /dev/null +++ b/tests/test_repository_label_reconciliation.py @@ -0,0 +1,427 @@ +"""Behavioral contracts for repository label taxonomy reconciliation.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_labels.py" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_labels", SCRIPT) +assert SPEC and SPEC.loader +LABELS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LABELS) + + +def write_taxonomy(tmp_path, **overrides): + """Write a compact valid taxonomy and return its path.""" + + payload = { + "schema_version": 1, + "type": { + "feature": "enhancement", + "bug": "bug", + "documentation": "documentation", + }, + "assignments": [ + {"repository": ".github", "issue": 1582, "type": "feature"}, + {"repository": "Repo", "issue": 1, "type": "documentation"}, + ], + } + payload.update(overrides) + path = tmp_path / "labels.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def completed(code=0, out="", err=""): + """Return a compact subprocess result for GitHub CLI probes.""" + + return subprocess.CompletedProcess( + args=["gh"], returncode=code, stdout=out, stderr=err + ) + + +def test_load_taxonomy_contracts(tmp_path) -> None: + """Taxonomy schema, mappings, targets, and casing fail closed.""" + + types, assignments = LABELS.load_taxonomy(write_taxonomy(tmp_path)) + assert types["feature"] == "enhancement" + assert assignments[0]["repository"] == ".github" + + bad_payloads = [ + [], + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [], + "extra": True, + }, + { + "schema_version": True, + "type": {"feature": "enhancement"}, + "assignments": [], + }, + {"schema_version": 1, "type": {}, "assignments": []}, + { + "schema_version": 1, + "type": {"feature": "x", "bug": "x"}, + "assignments": [], + }, + {"schema_version": 1, "type": {"feature": 1}, "assignments": []}, + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": {}, + }, + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [[]], + }, + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [ + { + "repository": "Repo", + "issue": 1, + "type": "feature", + "extra": True, + } + ], + }, + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [ + {"repository": "bad name", "issue": 1, "type": "feature"} + ], + }, + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [ + {"repository": "Repo", "issue": True, "type": "feature"} + ], + }, + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [ + {"repository": "Repo", "issue": 1, "type": "bug"} + ], + }, + { + "schema_version": 1, + "type": {"feature": "enhancement"}, + "assignments": [ + {"repository": "Repo", "issue": 1, "type": "feature"}, + {"repository": "Repo", "issue": 1, "type": "feature"}, + ], + }, + ] + for index, payload in enumerate(bad_payloads): + path = tmp_path / f"bad-{index}.json" + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(LABELS.TaxonomyError): + LABELS.load_taxonomy(path) + + +def test_gh_api_builds_json_and_handles_idempotent_not_found(monkeypatch) -> None: + """Label API calls serialize JSON, allow delete 404s, and fail closed otherwise.""" + + seen = [] + monkeypatch.setattr( + LABELS.subprocess, + "run", + lambda *args, **kwargs: seen.append((args, kwargs)) or completed(out="ok"), + ) + assert ( + LABELS._gh_api( + "POST", "repos/x/y/issues/1/labels", body={"labels": ["documentation"]} + ) + == "ok" + ) + assert seen[0][1]["input"] == '{"labels":["documentation"]}' + + responses = iter( + [ + completed(code=1, err="HTTP 404"), + completed(code=1, out="Not Found"), + completed(code=1, err="boom"), + completed(code=1, err="boom"), + ] + ) + monkeypatch.setattr( + LABELS.subprocess, + "run", + lambda *args, **kwargs: next(responses), + ) + assert ( + LABELS._gh_api( + "DELETE", "repos/x/y/issues/1/labels/bug", allow_not_found=True + ) + == "" + ) + assert ( + LABELS._gh_api( + "DELETE", "repos/x/y/issues/1/labels/bug", allow_not_found=True + ) + == "" + ) + with pytest.raises(RuntimeError, match="GitHub API request failed"): + LABELS._gh_api( + "DELETE", "repos/x/y/issues/1/labels/bug", allow_not_found=True + ) + with pytest.raises(RuntimeError, match="GitHub API request failed"): + LABELS._gh_api("GET", "repos/x/y/issues/1") + + +def test_label_names_accepts_github_shapes_and_rejects_malformed() -> None: + """Issue label extraction accepts strings/objects and rejects ambiguous payloads.""" + + assert LABELS._label_names({"labels": ["a", {"name": "b"}, "a"]}) == [ + "a", + "b", + ] + with pytest.raises(RuntimeError, match="labels payload"): + LABELS._label_names({"labels": {}}) + with pytest.raises(RuntimeError, match="entry"): + LABELS._label_names({"labels": [{}]}) + + +def test_reconcile_mutates_only_managed_labels_across_concurrent_updates( + monkeypatch, +) -> None: + """Concurrent unmanaged labels survive individual managed-label mutations.""" + + calls = [] + reads = iter( + [ + { + "labels": [ + {"name": "status: needs-review"}, + {"name": "old type"}, + ] + }, + { + "labels": [ + {"name": "status: needs-review"}, + {"name": "priority: high"}, + {"name": "documentation"}, + ] + }, + ] + ) + + def gh_api(method, endpoint, body=None, allow_not_found=False): + calls.append((method, endpoint, body, allow_not_found)) + if method == "GET": + return json.dumps(next(reads)) + return "" + + monkeypatch.setattr(LABELS, "_gh_api", gh_api) + LABELS.reconcile_assignment( + {"repository": "Repo", "issue": 1, "type": "documentation"}, + {"old": "old type", "documentation": "documentation"}, + ) + assert calls[1] == ( + "POST", + "repos/ContextualWisdomLab/Repo/issues/1/labels", + {"labels": ["documentation"]}, + False, + ) + assert calls[2] == ( + "DELETE", + "repos/ContextualWisdomLab/Repo/issues/1/labels/old%20type", + None, + True, + ) + assert calls[3][0] == "GET" + assert all(call[0] != "PATCH" for call in calls) + + +def test_reconcile_noops_and_rejects_failed_postcondition(monkeypatch) -> None: + """Converged assignments are write-free and failed managed postconditions fail.""" + + calls = [] + + def converged(method, endpoint, body=None, allow_not_found=False): + calls.append((method, endpoint, body, allow_not_found)) + return json.dumps( + { + "labels": [ + {"name": "status: needs-review"}, + {"name": "documentation"}, + ] + } + ) + + monkeypatch.setattr(LABELS, "_gh_api", converged) + LABELS.reconcile_assignment( + {"repository": "Repo", "issue": 1, "type": "documentation"}, + {"bug": "bug", "documentation": "documentation"}, + ) + assert [call[0] for call in calls] == ["GET"] + + responses = iter( + [ + json.dumps({"labels": [{"name": "bug"}]}), + "", + "", + json.dumps({"labels": [{"name": "bug"}]}), + ] + ) + monkeypatch.setattr( + LABELS, + "_gh_api", + lambda *args, **kwargs: next(responses), + ) + with pytest.raises(RuntimeError, match="managed labels did not converge"): + LABELS.reconcile_assignment( + {"repository": "Repo", "issue": 1, "type": "documentation"}, + {"bug": "bug", "documentation": "documentation"}, + ) + + monkeypatch.setattr(LABELS, "_gh_api", lambda *args, **kwargs: "[]") + with pytest.raises(LABELS.TaxonomyError, match="GitHub issue"): + LABELS.reconcile_assignment( + {"repository": "Repo", "issue": 1, "type": "documentation"}, + {"documentation": "documentation"}, + ) + + +def test_parse_args_and_main_modes(monkeypatch, tmp_path, capsys) -> None: + """Validation, filtering, authority, and fleet failure aggregation are enforced.""" + + path = write_taxonomy(tmp_path) + monkeypatch.setattr( + sys, + "argv", + ["prog", "--taxonomy", str(path), "--repository", "Repo"], + ) + args = LABELS.parse_args() + assert args.repository == ["Repo"] + + monkeypatch.setattr( + LABELS, + "parse_args", + lambda: argparse.Namespace( + taxonomy=path, validate_only=True, repository=[] + ), + ) + assert LABELS.main() == 0 + + monkeypatch.setattr( + LABELS, + "parse_args", + lambda: argparse.Namespace( + taxonomy=path, validate_only=False, repository=[] + ), + ) + monkeypatch.delenv("GH_TOKEN", raising=False) + with pytest.raises(RuntimeError, match="GH_TOKEN"): + LABELS.main() + + monkeypatch.setenv("GH_TOKEN", "x") + monkeypatch.setattr( + LABELS, + "parse_args", + lambda: argparse.Namespace( + taxonomy=path, validate_only=False, repository=["Missing"] + ), + ) + with pytest.raises(LABELS.TaxonomyError, match="undeclared"): + LABELS.main() + + seen = [] + monkeypatch.setattr( + LABELS, + "parse_args", + lambda: argparse.Namespace( + taxonomy=path, validate_only=False, repository=["Repo"] + ), + ) + monkeypatch.setattr( + LABELS, + "reconcile_assignment", + lambda assignment, type_map: seen.append(assignment["repository"]), + ) + assert LABELS.main() == 0 + assert seen == ["Repo"] + + seen.clear() + monkeypatch.setattr( + LABELS, + "parse_args", + lambda: argparse.Namespace( + taxonomy=path, validate_only=False, repository=[] + ), + ) + + def reconcile(assignment, type_map): + seen.append(assignment["repository"]) + if assignment["repository"] == ".github": + raise RuntimeError("boom") + + monkeypatch.setattr(LABELS, "reconcile_assignment", reconcile) + with pytest.raises(RuntimeError, match=r"\.github#1582"): + LABELS.main() + assert seen == [".github", "Repo"] + assert "label reconciliation failed" in capsys.readouterr().err + + monkeypatch.setattr(LABELS, "reconcile_assignment", lambda *args: None) + assert LABELS.main() == 0 + + +def test_main_catches_supported_errors(monkeypatch, tmp_path) -> None: + """Expected assignment failures are aggregated instead of stopping siblings.""" + + path = write_taxonomy( + tmp_path, + assignments=[{"repository": "Repo", "issue": 1, "type": "feature"}], + ) + monkeypatch.setenv("GH_TOKEN", "x") + monkeypatch.setattr( + LABELS, + "parse_args", + lambda: argparse.Namespace( + taxonomy=path, validate_only=False, repository=[] + ), + ) + exceptions = [ + LABELS.TaxonomyError("x"), + json.JSONDecodeError("x", "x", 0), + subprocess.TimeoutExpired("gh", 1), + ] + for exception in exceptions: + monkeypatch.setattr( + LABELS, + "reconcile_assignment", + lambda *args, exception=exception: (_ for _ in ()).throw(exception), + ) + with pytest.raises(RuntimeError, match="label reconciliation failed"): + LABELS.main() + + +def test_module_main_guard(monkeypatch, tmp_path) -> None: + """The executable entry point exits successfully in validation mode.""" + + path = write_taxonomy(tmp_path) + monkeypatch.setattr( + sys, + "argv", + [str(SCRIPT), "--taxonomy", str(path), "--validate-only"], + ) + with pytest.raises(SystemExit) as exc: + runpy.run_path(str(SCRIPT), run_name="__main__") + assert exc.value.code == 0 diff --git a/tests/test_repository_label_taxonomy.py b/tests/test_repository_label_taxonomy.py new file mode 100644 index 0000000000..0a9161c803 --- /dev/null +++ b/tests/test_repository_label_taxonomy.py @@ -0,0 +1,74 @@ +"""Contracts for the organization-wide repository label taxonomy.""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +TAXONOMY = ROOT / "config" / "repository-label-taxonomy.json" + + +def test_repository_label_taxonomy_maps_evidence_backed_types() -> None: + """Common semantic types and reviewed targets remain explicit and stable.""" + + payload = json.loads(TAXONOMY.read_text(encoding="utf-8")) + + assert payload["schema_version"] == 1 + assert payload["type"] == { + "feature": "enhancement", + "bug": "bug", + "documentation": "documentation", + } + # Keep assignments exact so reviewed target drift cannot silently escape CI. + assert payload["assignments"] == [ + {"repository": ".github", "issue": 1582, "type": "feature"}, + {"repository": "CalendarWeave", "issue": 1, "type": "documentation"}, + {"repository": "ConceptWeave", "issue": 1, "type": "feature"}, + { + "repository": "context-graph-contracts", + "issue": 20, + "type": "documentation", + }, + {"repository": "RankWeave", "issue": 40, "type": "documentation"}, + {"repository": "fast-mlsirm", "issue": 1717, "type": "documentation"}, + {"repository": "EgressWeave", "issue": 231, "type": "documentation"}, + { + "repository": "psychometrics-commons", + "issue": 442, + "type": "documentation", + }, + { + "repository": "contextual-orchestrator", + "issue": 994, + "type": "documentation", + }, + { + "repository": "contextual-orchestrator", + "issue": 1003, + "type": "documentation", + }, + {"repository": "appguardrail", "issue": 1077, "type": "documentation"}, + {"repository": "naruon", "issue": 1513, "type": "documentation"}, + {"repository": "LineageWeave", "issue": 908, "type": "documentation"}, + { + "repository": "ContextualWisdomLab.github.io", + "issue": 203, + "type": "documentation", + }, + {"repository": "TEPP", "issue": 435, "type": "documentation"}, + { + "repository": "semantic-data-portal", + "issue": 72, + "type": "documentation", + }, + {"repository": "Orgmetra", "issue": 160, "type": "documentation"}, + { + "repository": "learning-interoperability-contracts", + "issue": 1, + "type": "feature", + }, + {"repository": "noema", "issue": 530, "type": "feature"}, + ] + assert len(set(payload["type"].values())) == len(payload["type"]) diff --git a/tests/test_repository_metadata_convergence.py b/tests/test_repository_metadata_convergence.py new file mode 100644 index 0000000000..e43c7aaccf --- /dev/null +++ b/tests/test_repository_metadata_convergence.py @@ -0,0 +1,86 @@ +"""Focused convergence regressions for repository metadata reconciliation.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_metadata.py" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_metadata", SCRIPT) +assert SPEC and SPEC.loader +RECONCILER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RECONCILER) + + +def desired(**overrides): + """Return one minimal desired-state record.""" + + state = { + "description": "Useful product.", + "topics": ["python", "tooling"], + "deepwiki": False, + "pages": False, + } + state.update(overrides) + return state + + +def test_topic_order_does_not_trigger_rewrite(monkeypatch) -> None: + """GitHub topic ordering is treated as presentation, not desired-state drift.""" + + calls = [] + + def gh_api(method, endpoint, **kwargs): + calls.append((method, endpoint, kwargs)) + if endpoint.endswith("/topics"): + return json.dumps({"names": ["tooling", "python"]}) + return json.dumps( + {"default_branch": "main", "description": "Useful product."} + ) + + monkeypatch.setattr(RECONCILER, "_gh_api", gh_api) + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: False) + monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: False) + + RECONCILER.reconcile_repository("Repo", desired()) + + assert [method for method, _, _ in calls] == ["GET", "GET"] + + +def test_duplicate_repository_filters_run_once(monkeypatch, tmp_path) -> None: + """Repeated narrow repository arguments never duplicate privileged writes.""" + + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "organization": RECONCILER.ORGANIZATION, + "repositories": {"Repo": desired(topics=["python"])}, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("GH_TOKEN", "token") + monkeypatch.setattr( + RECONCILER, + "parse_args", + lambda: argparse.Namespace( + manifest=manifest, + validate_only=False, + repository=["Repo", "Repo", "Repo"], + ), + ) + seen = [] + monkeypatch.setattr( + RECONCILER, + "reconcile_repository", + lambda repository, state: seen.append(repository), + ) + + assert RECONCILER.main() == 0 + assert seen == ["Repo"] diff --git a/tests/test_repository_metadata_identity.py b/tests/test_repository_metadata_identity.py new file mode 100644 index 0000000000..3063b4168a --- /dev/null +++ b/tests/test_repository_metadata_identity.py @@ -0,0 +1,60 @@ +"""Repository identity regressions for metadata desired state.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_metadata.py" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_metadata", SCRIPT) +assert SPEC and SPEC.loader +RECONCILER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RECONCILER) + + +def desired() -> dict[str, object]: + """Return a minimal valid desired-state record.""" + + return { + "description": "Useful product.", + "topics": ["python"], + "deepwiki": False, + "pages": False, + } + + +def test_manifest_rejects_case_only_repository_collisions(tmp_path: Path) -> None: + """GitHub case aliases cannot own conflicting desired-state records.""" + + path = tmp_path / "manifest.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "organization": RECONCILER.ORGANIZATION, + "repositories": {"Repo": desired(), "repo": desired()}, + } + ), + encoding="utf-8", + ) + + with pytest.raises(RECONCILER.ManifestError, match="casing collision"): + RECONCILER.load_manifest(path) + + +def test_repository_filters_use_reviewed_casing_and_deduplicate_aliases() -> None: + """Operator filters normalize GitHub identity without changing API casing.""" + + repositories = {"Repo": desired(), "OtherRepo": desired()} + + assert RECONCILER._select_repositories([], repositories) == ["Repo", "OtherRepo"] + assert RECONCILER._select_repositories( + ["repo", "REPO", "OtherRepo"], repositories + ) == ["Repo", "OtherRepo"] + with pytest.raises(RECONCILER.ManifestError, match="undeclared"): + RECONCILER._select_repositories(["missing"], repositories) diff --git a/tests/test_repository_metadata_live_verification.py b/tests/test_repository_metadata_live_verification.py new file mode 100644 index 0000000000..7914d7bfa5 --- /dev/null +++ b/tests/test_repository_metadata_live_verification.py @@ -0,0 +1,297 @@ +"""Live post-apply verification contracts for repository public metadata.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_metadata.py" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_metadata", SCRIPT) +assert SPEC and SPEC.loader +RECONCILER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RECONCILER) + + +def desired(**overrides): + """Return one minimal desired public state.""" + + state = { + "description": "Useful product.", + "topics": ["python"], + "deepwiki": False, + "pages": False, + } + state.update(overrides) + return state + + +class FakeResponse: + """Minimal context-managed HTTPS response used by Pages reachability tests.""" + + def __init__(self, payload=b"x"): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def read(self, size=-1): + return self.payload[:size] + + +class FakeOpener: + """Minimal redirect-controlled opener used by Pages reachability tests.""" + + def __init__(self, *, response=None, error=None, seen=None): + self.response = response or FakeResponse() + self.error = error + self.seen = seen + + def open(self, request, timeout): + if self.seen is not None: + self.seen.append((request.full_url, request.headers["User-agent"], timeout)) + if self.error is not None: + raise self.error + return self.response + + +def install_live_state( + monkeypatch, + *, + description="Useful product.", + default_branch="main", + topics=None, + badge=False, + docs=False, + pages=False, + page_config=None, +): + """Install deterministic live-state probes for verification tests.""" + + if topics is None: + topics = ["python"] + if page_config is None: + page_config = { + "build_type": "legacy", + "status": "built", + "html_url": "https://contextualwisdomlab.github.io/Repo/", + "source": {"branch": default_branch, "path": "/docs"}, + } + + def gh_api(method, endpoint, **kwargs): + assert method == "GET" + if endpoint.endswith("/topics"): + return json.dumps({"names": topics}) + return json.dumps( + {"default_branch": default_branch, "description": description} + ) + + monkeypatch.setattr(RECONCILER, "_gh_api", gh_api) + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: badge) + monkeypatch.setattr(RECONCILER, "_docs_index_exists", lambda *args: docs) + monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: pages) + monkeypatch.setattr( + RECONCILER, "_pages_configuration", lambda *args: page_config + ) + monkeypatch.setattr( + RECONCILER, "build_opener", lambda *args: FakeOpener() + ) + + +def test_pages_publication_ready_confines_origin_redirects_and_content( + monkeypatch, +) -> None: + """Published Pages checks stay on the owned origin and require non-empty content.""" + + ready = { + "status": "built", + "html_url": "https://contextualwisdomlab.github.io/Repo/", + } + seen = [] + handlers = [] + + def build_ok(handler): + handlers.append(handler) + return FakeOpener(response=FakeResponse(b"published"), seen=seen) + + monkeypatch.setattr(RECONCILER, "build_opener", build_ok) + assert RECONCILER._pages_url_is_expected(RECONCILER.PAGES_BASE_URL) + assert RECONCILER._pages_url_is_expected(ready["html_url"]) + assert not RECONCILER._pages_url_is_expected(None) + assert not RECONCILER._pages_url_is_expected("https://example.com/") + assert not RECONCILER._pages_url_is_expected( + "https://contextualwisdomlab.github.io.evil.example/" + ) + assert not RECONCILER._pages_url_is_expected( + "https://contextualwisdomlab.github.io@127.0.0.1/" + ) + + RECONCILER._pages_publication_ready("Repo", ready) + assert seen == [ + ( + "https://contextualwisdomlab.github.io/Repo/", + "ContextualWisdomLab-repository-metadata-reconcile", + 10, + ) + ] + assert len(handlers) == 1 + assert isinstance(handlers[0], RECONCILER._NoPagesRedirects) + assert ( + handlers[0].redirect_request( + None, None, 302, "redirect", {}, "http://127.0.0.1/" + ) + is None + ) + + with pytest.raises(RuntimeError, match="not built"): + RECONCILER._pages_publication_ready("Repo", {**ready, "status": "building"}) + for unsafe_url in [ + "http://contextualwisdomlab.github.io/Repo/", + "https://example.com/", + "https://contextualwisdomlab.github.io.evil.example/", + ]: + with pytest.raises(RuntimeError, match="URL is invalid"): + RECONCILER._pages_publication_ready( + "Repo", {**ready, "html_url": unsafe_url} + ) + + monkeypatch.setattr( + RECONCILER, + "build_opener", + lambda *args: FakeOpener(response=FakeResponse(b"")), + ) + with pytest.raises(RuntimeError, match="empty content"): + RECONCILER._pages_publication_ready("Repo", ready) + + monkeypatch.setattr( + RECONCILER, + "build_opener", + lambda *args: FakeOpener(error=RECONCILER.URLError("offline")), + ) + with pytest.raises(RuntimeError, match="not reachable"): + RECONCILER._pages_publication_ready("Repo", ready) + + +def test_verify_repository_accepts_converged_disabled_and_enabled_pages( + monkeypatch, +) -> None: + """Verification succeeds only on freshly re-read converged public state.""" + + install_live_state(monkeypatch) + RECONCILER.verify_repository("Repo", desired()) + + install_live_state(monkeypatch, badge=True, docs=True, pages=True) + RECONCILER.verify_repository("Repo", desired(deepwiki=True, pages=True)) + + +@pytest.mark.parametrize( + ("state", "wanted", "message"), + [ + ({"default_branch": ""}, {}, "default branch"), + ({"description": "wrong"}, {}, "description did not converge"), + ({"topics": ["wrong"]}, {}, "topics did not converge"), + ({"badge": True}, {}, "DeepWiki state did not converge"), + ( + {"badge": True, "docs": False}, + {"deepwiki": True, "pages": True}, + "Pages source did not converge", + ), + ( + {"badge": True, "docs": True, "pages": False}, + {"deepwiki": True, "pages": True}, + "was not published", + ), + ( + { + "badge": True, + "docs": True, + "pages": True, + "page_config": { + "build_type": "workflow", + "status": "built", + "html_url": "https://contextualwisdomlab.github.io/Repo/", + "source": {"branch": "main", "path": "/docs"}, + }, + }, + {"deepwiki": True, "pages": True}, + "configuration did not converge", + ), + ({"pages": True}, {}, "remained published"), + ], +) +def test_verify_repository_rejects_every_public_surface_drift( + monkeypatch, state, wanted, message +) -> None: + """Each independently observable public-surface mismatch fails verification.""" + + install_live_state(monkeypatch, **state) + with pytest.raises(RuntimeError, match=message): + RECONCILER.verify_repository("Repo", desired(**wanted)) + + +def test_verify_repository_rejects_unready_published_pages(monkeypatch) -> None: + """A correctly configured but still-building Pages site is not completion.""" + + install_live_state( + monkeypatch, + badge=True, + docs=True, + pages=True, + page_config={ + "build_type": "legacy", + "status": "building", + "html_url": "https://contextualwisdomlab.github.io/Repo/", + "source": {"branch": "main", "path": "/docs"}, + }, + ) + with pytest.raises(RuntimeError, match="not built"): + RECONCILER.verify_repository("Repo", desired(deepwiki=True, pages=True)) + + +def test_main_verify_only_uses_read_only_verifier(monkeypatch, tmp_path: Path) -> None: + """Verify-only mode never calls the mutation path.""" + + manifest = tmp_path / "manifest.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "organization": RECONCILER.ORGANIZATION, + "repositories": {"Repo": desired()}, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("GH_TOKEN", "token") + monkeypatch.setattr( + RECONCILER, + "parse_args", + lambda: argparse.Namespace( + manifest=manifest, + validate_only=False, + verify_only=True, + repository=[], + ), + ) + seen = [] + monkeypatch.setattr( + RECONCILER, + "verify_repository", + lambda repository, state: seen.append(repository), + ) + monkeypatch.setattr( + RECONCILER, + "reconcile_repository", + lambda *args: pytest.fail("mutation path used in verify-only mode"), + ) + + assert RECONCILER.main() == 0 + assert seen == ["Repo"] diff --git a/tests/test_repository_metadata_reconciliation.py b/tests/test_repository_metadata_reconciliation.py new file mode 100644 index 0000000000..f6ad0369d2 --- /dev/null +++ b/tests/test_repository_metadata_reconciliation.py @@ -0,0 +1,559 @@ +"""Behavioral contracts for fleet repository metadata reconciliation.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import runpy +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_repository_metadata.py" +MANIFEST = ROOT / "config" / "repository-metadata.json" +SPEC = importlib.util.spec_from_file_location("reconcile_repository_metadata", SCRIPT) +assert SPEC and SPEC.loader +RECONCILER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(RECONCILER) + + +def desired(**overrides): + """Return a minimal valid repository desired-state record.""" + + data = { + "description": "Useful product.", + "topics": ["python"], + "deepwiki": False, + "pages": False, + } + data.update(overrides) + return data + + +def write_manifest(tmp_path, repositories=None, **root_overrides): + """Write a test manifest and return its path.""" + + payload = { + "schema_version": 1, + "organization": RECONCILER.ORGANIZATION, + "repositories": repositories or {"Repo": desired()}, + } + payload.update(root_overrides) + path = tmp_path / "manifest.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def completed(code=0, out="", err=""): + """Return a compact subprocess result for GitHub CLI probes.""" + + return subprocess.CompletedProcess( + args=["gh"], returncode=code, stdout=out, stderr=err + ) + + +def test_metadata_manifest_declares_exact_casing_and_public_surfaces() -> None: + """The reviewed manifest preserves exact repository casing and surface intent.""" + + payload = json.loads(MANIFEST.read_text(encoding="utf-8")) + repositories = payload["repositories"] + expected = { + "CalendarWeave": ("calendar", "icalendar"), + "ConceptWeave": ("semantic-model", "ontology"), + "context-graph-contracts": ("interoperability", "cloudevents"), + "ThreadWeave": ("rfc5256", "python"), + "RankWeave": ("information-retrieval", "trec"), + "fast-mlsirm": ("psychometrics", "rust"), + "EgressWeave": ("ssrf", "python"), + "psychometrics-commons": ("psychometrics", "rust"), + } + assert set(repositories) == set(expected) + for repository, required_topics in expected.items(): + state = repositories[repository] + assert state["deepwiki"] is True + assert state["pages"] is True + assert all(topic in state["topics"] for topic in required_topics) + + +def test_require_exact_dict_and_repository_validation() -> None: + """Malformed desired state fails closed across every field family.""" + + assert RECONCILER._require_exact_dict({}, field="x") == {} + with pytest.raises(RECONCILER.ManifestError, match="must be an object"): + RECONCILER._require_exact_dict([], field="x") + + valid = desired() + assert RECONCILER._validate_repository("Repo", valid) == valid + for name in [1, "bad name"]: + with pytest.raises(RECONCILER.ManifestError, match="exact GitHub-safe casing"): + RECONCILER._validate_repository(name, valid) + with pytest.raises(RECONCILER.ManifestError, match="contain exactly"): + RECONCILER._validate_repository("Repo", {**valid, "extra": True}) + + descriptions = [ + None, + "", + "x" * 351, + "do not publish", + "issue #7", + "https://example.com", + ] + for description in descriptions: + with pytest.raises(RECONCILER.ManifestError): + RECONCILER._validate_repository( + "Repo", {**valid, "description": description} + ) + + topic_cases = [None, [], ["x"] * 21, [1], ["Bad_Topic"], ["dup", "dup"]] + for topics in topic_cases: + with pytest.raises(RECONCILER.ManifestError): + RECONCILER._validate_repository("Repo", {**valid, "topics": topics}) + + for field, value in [("deepwiki", 1), ("pages", "yes")]: + with pytest.raises(RECONCILER.ManifestError): + RECONCILER._validate_repository("Repo", {**valid, field: value}) + + +def test_load_manifest_contracts(tmp_path) -> None: + """Manifest root schema, ownership, and non-empty fleet scope are enforced.""" + + path = write_manifest(tmp_path) + assert list(RECONCILER.load_manifest(path)) == ["Repo"] + + path.write_text(json.dumps([]), encoding="utf-8") + with pytest.raises(RECONCILER.ManifestError, match="manifest must be an object"): + RECONCILER.load_manifest(path) + + cases = [ + ( + { + "schema_version": 1, + "organization": RECONCILER.ORGANIZATION, + "repositories": {}, + "extra": 1, + }, + "unexpected key", + ), + ( + { + "schema_version": 2, + "organization": RECONCILER.ORGANIZATION, + "repositories": {}, + }, + "schema or organization", + ), + ( + { + "schema_version": True, + "organization": RECONCILER.ORGANIZATION, + "repositories": {"Repo": desired()}, + }, + "schema or organization", + ), + ( + {"schema_version": 1, "organization": "Other", "repositories": {}}, + "schema or organization", + ), + ( + { + "schema_version": 1, + "organization": RECONCILER.ORGANIZATION, + "repositories": [], + }, + "repositories must be an object", + ), + ( + { + "schema_version": 1, + "organization": RECONCILER.ORGANIZATION, + "repositories": {}, + }, + "at least one repository", + ), + ] + for payload, message in cases: + path.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(RECONCILER.ManifestError, match=message): + RECONCILER.load_manifest(path) + + +def test_gh_api_builds_requests_and_fails_closed(monkeypatch) -> None: + """GitHub API writes serialize bounded JSON and reject non-zero exits.""" + + seen = [] + monkeypatch.setattr( + RECONCILER.subprocess, + "run", + lambda *args, **kwargs: seen.append((args, kwargs)) or completed(out="ok"), + ) + assert ( + RECONCILER._gh_api( + "PATCH", "repos/x/y", fields={"a": "b"}, body={"z": 1} + ) + == "ok" + ) + args, kwargs = seen[0] + assert args[0][:5] == ["gh", "api", "--method", "PATCH", "repos/x/y"] + assert "--input" in args[0] and "--field" in args[0] + assert kwargs["input"] == '{"z":1}' + + monkeypatch.setattr( + RECONCILER.subprocess, + "run", + lambda *args, **kwargs: completed(code=1), + ) + with pytest.raises(RuntimeError, match="GitHub API request failed"): + RECONCILER._gh_api("GET", "repos/x/y") + + +def test_pages_and_docs_probes(monkeypatch) -> None: + """Pages and source probes distinguish present, absent, and unknown states.""" + + responses = iter( + [completed(), completed(code=1, err="HTTP 404"), completed(code=1, err="boom")] + ) + monkeypatch.setattr( + RECONCILER.subprocess, "run", lambda *args, **kwargs: next(responses) + ) + assert RECONCILER._pages_exists("Repo") is True + assert RECONCILER._pages_exists("Repo") is False + with pytest.raises(RuntimeError, match="Pages state"): + RECONCILER._pages_exists("Repo") + + responses = iter( + [completed(), completed(code=1, out="Not Found"), completed(code=1, err="boom")] + ) + monkeypatch.setattr( + RECONCILER.subprocess, "run", lambda *args, **kwargs: next(responses) + ) + assert RECONCILER._docs_index_exists("Repo", "main") is True + assert RECONCILER._docs_index_exists("Repo", "main") is False + with pytest.raises(RuntimeError, match="Pages source state"): + RECONCILER._docs_index_exists("Repo", "main") + + +def test_pages_configuration_contracts(monkeypatch) -> None: + """Pages state is parsed exactly and converged legacy /docs sites are recognized.""" + + monkeypatch.setattr( + RECONCILER, + "_gh_api", + lambda *args, **kwargs: json.dumps( + { + "build_type": "legacy", + "source": {"branch": "main", "path": "/docs"}, + } + ), + ) + current = RECONCILER._pages_configuration("Repo") + assert RECONCILER._pages_configuration_matches(current, "main") is True + assert RECONCILER._pages_configuration_matches({}, "main") is False + assert ( + RECONCILER._pages_configuration_matches( + {"source": {"branch": "develop", "path": "/docs"}}, "main" + ) + is False + ) + assert ( + RECONCILER._pages_configuration_matches( + {"source": {"branch": "main", "path": "/"}}, "main" + ) + is False + ) + assert ( + RECONCILER._pages_configuration_matches( + { + "build_type": "workflow", + "source": {"branch": "main", "path": "/docs"}, + }, + "main", + ) + is False + ) + monkeypatch.setattr(RECONCILER, "_gh_api", lambda *args, **kwargs: "[]") + with pytest.raises(RECONCILER.ManifestError, match="Pages configuration"): + RECONCILER._pages_configuration("Repo") + + +def test_deepwiki_requires_one_linked_badge(monkeypatch) -> None: + """Disconnected, wrong-case, and wrong-target DeepWiki badges are rejected.""" + + target = f"https://deepwiki.com/{RECONCILER.ORGANIZATION}/Repo" + image = "https://deepwiki.com/badge.svg" + assert RECONCILER._deepwiki_badge_linked( + f"[![Ask DeepWiki]({image})]({target})", "Repo" + ) + assert RECONCILER._deepwiki_badge_linked( + f'Ask', + "Repo", + ) + assert not RECONCILER._deepwiki_badge_linked( + f'' + f'', + "Repo", + ) + assert not RECONCILER._deepwiki_badge_linked(f"{image}\n{target}", "Repo") + assert not RECONCILER._deepwiki_badge_linked( + f"[![Ask]({image})]" + f"(https://deepwiki.com/{RECONCILER.ORGANIZATION}/Other)", + "Repo", + ) + assert not RECONCILER._deepwiki_badge_linked( + f'DeepWiki', + "Repo", + ) + assert not RECONCILER._deepwiki_badge_linked( + f'DeepWiki' + f'', + "Repo", + ) + + responses = iter( + [ + completed(out=f"[![Ask]({image})]({target})"), + completed(code=1, err="HTTP 404"), + completed(code=1, err="boom"), + ] + ) + monkeypatch.setattr( + RECONCILER.subprocess, "run", lambda *args, **kwargs: next(responses) + ) + assert RECONCILER._deepwiki_badge_exists("Repo", "main") is True + assert RECONCILER._deepwiki_badge_exists("Repo", "main") is False + with pytest.raises(RuntimeError, match="README state"): + RECONCILER._deepwiki_badge_exists("Repo", "main") + + +def test_reconcile_preconditions(monkeypatch) -> None: + """Public-surface prerequisites block writes only for their own repository.""" + + monkeypatch.setattr( + RECONCILER, + "_gh_api", + lambda method, endpoint, **kwargs: ( + json.dumps({"default_branch": "main"}) if method == "GET" else "" + ), + ) + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: False) + with pytest.raises(RuntimeError, match="DeepWiki badge requested"): + RECONCILER.reconcile_repository("Repo", desired(deepwiki=True)) + + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: True) + with pytest.raises(RuntimeError, match="DeepWiki badge is disabled"): + RECONCILER.reconcile_repository("Repo", desired()) + + monkeypatch.setattr(RECONCILER, "_docs_index_exists", lambda *args: False) + with pytest.raises(RuntimeError, match="Pages requested"): + RECONCILER.reconcile_repository("Repo", desired(deepwiki=True, pages=True)) + + monkeypatch.setattr( + RECONCILER, + "_gh_api", + lambda *args, **kwargs: json.dumps({"default_branch": None}), + ) + with pytest.raises(RuntimeError, match="default branch"): + RECONCILER.reconcile_repository("Repo", desired()) + + +def test_reconcile_mutation_matrix(monkeypatch) -> None: + """Descriptions, topics, Pages create/update/disable all reconcile.""" + + calls = [] + + def gh_api(method, endpoint, **kwargs): + calls.append((method, endpoint, kwargs)) + if method == "GET" and endpoint.endswith("/topics"): + return json.dumps({"names": ["old"]}) + if method == "GET" and endpoint.endswith("/pages"): + return json.dumps( + {"build_type": "workflow", "source": {"branch": "main", "path": "/"}} + ) + if method == "GET": + return json.dumps({"default_branch": "main", "description": "old"}) + return "" + + monkeypatch.setattr(RECONCILER, "_gh_api", gh_api) + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: True) + monkeypatch.setattr(RECONCILER, "_docs_index_exists", lambda *args: True) + monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: False) + RECONCILER.reconcile_repository( + "Repo", + desired( + description="new", + topics=["new"], + deepwiki=True, + pages=True, + ), + ) + assert any(call[0] == "PATCH" for call in calls) + assert any(call[0] == "PUT" and call[1].endswith("/topics") for call in calls) + assert any(call[0] == "POST" and call[1].endswith("/pages") for call in calls) + + calls.clear() + monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: True) + RECONCILER.reconcile_repository( + "Repo", desired(description="new", topics=["new"], deepwiki=True, pages=True) + ) + assert any(call[0] == "PUT" and call[1].endswith("/pages") for call in calls) + + calls.clear() + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: False) + RECONCILER.reconcile_repository( + "Repo", desired(description="new", topics=["new"], pages=False) + ) + assert any(call[0] == "DELETE" and call[1].endswith("/pages") for call in calls) + + +def test_reconcile_noops_when_already_desired(monkeypatch) -> None: + """Already-converged repository and Pages state cause no writes.""" + + calls = [] + + def gh_api(method, endpoint, **kwargs): + calls.append((method, endpoint, kwargs)) + if endpoint.endswith("/topics"): + return json.dumps({"names": ["python"]}) + if endpoint.endswith("/pages"): + return json.dumps( + { + "build_type": "legacy", + "source": {"branch": "main", "path": "/docs"}, + } + ) + return json.dumps( + {"default_branch": "main", "description": "Useful product."} + ) + + monkeypatch.setattr(RECONCILER, "_gh_api", gh_api) + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: False) + monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: False) + RECONCILER.reconcile_repository("Repo", desired()) + assert [call[0] for call in calls] == ["GET", "GET"] + + calls.clear() + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: True) + monkeypatch.setattr(RECONCILER, "_docs_index_exists", lambda *args: True) + monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: True) + RECONCILER.reconcile_repository("Repo", desired(deepwiki=True, pages=True)) + assert [call[0] for call in calls] == ["GET", "GET", "GET"] + + +def test_parse_args(monkeypatch, tmp_path) -> None: + """CLI supports validation and narrow repository selection.""" + + path = tmp_path / "m.json" + monkeypatch.setattr( + sys, + "argv", + [ + "prog", + "--manifest", + str(path), + "--validate-only", + "--repository", + "Repo", + ], + ) + args = RECONCILER.parse_args() + assert args.manifest == path + assert args.validate_only is True + assert args.repository == ["Repo"] + + +def test_main_modes_and_failure_aggregation(monkeypatch, tmp_path, capsys) -> None: + """Apply mode requires authority and continues siblings before aggregating errors.""" + + path = write_manifest(tmp_path, {"A": desired(), "B": desired()}) + monkeypatch.setattr( + RECONCILER, + "parse_args", + lambda: argparse.Namespace(manifest=path, validate_only=True, repository=[]), + ) + assert RECONCILER.main() == 0 + + monkeypatch.setattr( + RECONCILER, + "parse_args", + lambda: argparse.Namespace(manifest=path, validate_only=False, repository=[]), + ) + monkeypatch.delenv("GH_TOKEN", raising=False) + with pytest.raises(RuntimeError, match="GH_TOKEN"): + RECONCILER.main() + + monkeypatch.setenv("GH_TOKEN", "x") + monkeypatch.setattr( + RECONCILER, + "parse_args", + lambda: argparse.Namespace( + manifest=path, + validate_only=False, + repository=["Missing"], + ), + ) + with pytest.raises(RECONCILER.ManifestError, match="undeclared"): + RECONCILER.main() + + monkeypatch.setattr( + RECONCILER, + "parse_args", + lambda: argparse.Namespace(manifest=path, validate_only=False, repository=[]), + ) + seen = [] + + def reconcile(repository, state): + seen.append(repository) + if repository == "A": + raise RuntimeError("boom") + + monkeypatch.setattr(RECONCILER, "reconcile_repository", reconcile) + with pytest.raises(RuntimeError, match="A: boom"): + RECONCILER.main() + assert seen == ["A", "B"] + assert "failed for A" in capsys.readouterr().err + + monkeypatch.setattr(RECONCILER, "reconcile_repository", lambda *args: None) + assert RECONCILER.main() == 0 + + +def test_main_catches_supported_errors(monkeypatch, tmp_path) -> None: + """Expected per-repository runtime failures are aggregated consistently.""" + + path = write_manifest(tmp_path) + monkeypatch.setenv("GH_TOKEN", "x") + monkeypatch.setattr( + RECONCILER, + "parse_args", + lambda: argparse.Namespace(manifest=path, validate_only=False, repository=[]), + ) + exceptions = [ + RECONCILER.ManifestError("x"), + json.JSONDecodeError("x", "x", 0), + subprocess.TimeoutExpired("gh", 1), + ] + for exception in exceptions: + monkeypatch.setattr( + RECONCILER, + "reconcile_repository", + lambda *args, exception=exception: (_ for _ in ()).throw(exception), + ) + with pytest.raises(RuntimeError, match="metadata reconciliation failed"): + RECONCILER.main() + + +def test_module_main_guard(monkeypatch, tmp_path) -> None: + """The executable entry point exits successfully for validation mode.""" + + path = write_manifest(tmp_path) + monkeypatch.setattr( + sys, + "argv", + [str(SCRIPT), "--manifest", str(path), "--validate-only"], + ) + with pytest.raises(SystemExit) as exc: + runpy.run_path(str(SCRIPT), run_name="__main__") + assert exc.value.code == 0 diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index a5079daa67..30938914f8 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -313,9 +313,7 @@ def test_central_semgrep_binds_pr_scans_and_sarif_to_the_exact_head() -> None: "repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}" in checkout ) - assert ( - "ref: ${{ github.event.pull_request.head.sha || github.sha }}" in checkout - ) + assert "ref: ${{ github.event.pull_request.head.sha || github.sha }}" in checkout assert "persist-credentials: false" in checkout assert ( "EXPECTED_CHECKOUT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}" @@ -328,27 +326,26 @@ def test_central_semgrep_binds_pr_scans_and_sarif_to_the_exact_head() -> None: "ref: ${{ github.event_name == 'pull_request' && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}" in upload ) - assert ( - "sha: ${{ github.event.pull_request.head.sha || github.sha }}" in upload - ) + assert "sha: ${{ github.event.pull_request.head.sha || github.sha }}" in upload def test_strix_serializes_provider_evidence_per_repository() -> None: - """Serialize Strix per repository so shared provider keys are not rate-limited. - - Root cause (2026-08-23/24): sibling PRs scanned concurrently, each retrying - the shared NVIDIA NIM key three times, producing litellm.RateLimitError - storms and fail-closed gate failures on every open PR. The concurrency group - now scopes the scan job per repository and event class. The cleanup job is - outside that queue so a synchronize event can immediately retire an older - exact-head run without allowing sibling scans to overlap. - """ + """Retire stale PR runs safely while preserving provider serialization.""" workflow = workflow_text("strix.yml") - concurrency_contract = workflow.split("concurrency:", 1)[1].split( - "permissions:", 1 + pre_jobs = workflow.split("jobs:", 1)[0] + strix_job = workflow.split(" strix:", 1)[1] + concurrency_contract = strix_job.split("concurrency:", 1)[1].split( + "runs-on:", 1 )[0] + scheduler = ( + REPO_ROOT / "scripts" / "ci" / "pr_review_merge_scheduler.py" + ).read_text(encoding="utf-8") + + assert "strix-workflow-${{" not in pre_jobs + assert "cancel-in-progress:" not in pre_jobs + assert "cancel-superseded-pr-runs:" not in workflow + assert "cancel_stale_pr_runs(repo, pr, dry_run=dry_run)" in scheduler - assert "concurrency:" in workflow assert "github.event.client_payload.target_repository" in concurrency_contract assert "github.event.pull_request.base.repo.full_name" in concurrency_contract assert "github.repository" in concurrency_contract @@ -360,38 +357,11 @@ def test_strix_serializes_provider_evidence_per_repository() -> None: "format('{0}-{1}-{2}', github.event_name, github.repository, github.ref)" in concurrency_contract ) - # Repository-level (not PR-level) grouping: no pr-{N} component remains. - assert "format('pr-{0}', github.event.pull_request.number)" not in concurrency_contract + assert "github.event.pull_request.number" not in concurrency_contract assert "github.event.pull_request.head.sha" not in concurrency_contract assert "github.event.client_payload.pr_head_sha" not in concurrency_contract - # Running scans are not cancelled; GitHub's native group has one pending slot. - assert "cancel-in-progress: false" in workflow - assert "cancel-in-progress: true" not in workflow.split("jobs:", 1)[0] + assert "cancel-in-progress: false" in concurrency_contract assert "queue: max" not in workflow - assert workflow.index("cancel-superseded-pr-runs:") < workflow.index("concurrency:") - cleanup_job = workflow.split(" cancel-superseded-pr-runs:", 1)[1].split( - " strix:", 1 - )[0] - assert "github.event.action == 'synchronize'" in cleanup_job - assert 'endswith("@" + $head_sha)' in cleanup_job - assert "/force-cancel" in cleanup_job - assert 'gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}"' in cleanup_job - assert "could not verify the live pull request" in cleanup_job - assert "target changed before run selection" in cleanup_job - assert "target changed before cancellation" in cleanup_job - assert cleanup_job.index("if ! live_target_matches") < cleanup_job.index( - 'runs_url="repos/${TARGET_REPOSITORY}/actions/runs?status=${status}&per_page=100"' - ) - assert cleanup_job.rindex("if ! live_target_matches") < cleanup_job.index( - 'gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel"' - ) - assert "actions: write" in cleanup_job - assert "pull-requests: read" in cleanup_job - assert "actions/checkout" not in cleanup_job - assert ( - "refs/pull//head has already advanced before this queued run starts" - in workflow - ) def test_strix_install_normalizes_executable_permissions_before_hashing() -> None: @@ -411,128 +381,8 @@ def test_strix_install_normalizes_executable_permissions_before_hashing() -> Non ) -def test_strix_cleanup_uses_pr_metadata_when_custom_title_is_absent() -> None: - """Required-workflow runs retain exact PR/head cleanup without run-name rendering.""" - jq = shutil.which("jq") - if jq is None: - pytest.skip("jq is required to execute the production cleanup selector") - workflow = workflow_text("strix.yml") - marker = '--arg action "$PR_ACTION" --arg repo "$TARGET_REPOSITORY" --arg current "$CURRENT_RUN_ID" \'\n' - start = workflow.index(marker) + len(marker) - end = workflow.index('\n \' <<<"$runs_json"', start) - runs = { - "workflow_runs": [ - {"id": 1, "name": "Strix Security Scan", "event": "pull_request_target", "pull_requests": [{"number": 7, "head": {"sha": "old"}}]}, - {"id": 2, "name": "Strix Security Scan", "event": "pull_request_target", "pull_requests": [{"number": 7, "head": {"sha": "current"}}]}, - {"id": 3, "name": "Strix Security Scan", "event": "pull_request_target", "pull_requests": [{"number": 7}]}, - {"id": 4, "name": "Strix Security Scan", "event": "pull_request_target", "display_title": "Strix Security Scan owner/repo#7@old", "pull_requests": [{"number": 7, "head": {"sha": "current"}}]}, - {"id": 5, "name": "Strix Security Scan", "event": "pull_request_target", "pull_requests": [{"number": 8, "head": {"sha": "old"}}]}, - ] - } - result = subprocess.run( - [jq, "-r", "--arg", "pr", "7", "--arg", "head_sha", "current", "--arg", "action", "synchronize", "--arg", "repo", "owner/repo", "--arg", "current", "99", workflow[start:end]], - input=json.dumps(runs), - text=True, - capture_output=True, - check=True, - ) - assert result.stdout.splitlines() == ["1"] - - -def _run_strix_cleanup(tmp_path: Path, pull_states: list[dict[str, object]]) -> str: - """Execute the production cleanup step against a stateful fake ``gh``.""" - jq = shutil.which("jq") - if jq is None: - pytest.skip("jq is required to execute the production cleanup") - step = workflow_step( - workflow_text("strix.yml"), - "Cancel queued and running scans for superseded or closed pull request heads", - ) - run_block = step.split(" run: |\n", 1)[1].split("\n strix:", 1)[0] - script = textwrap.dedent(run_block) - fake_bin = tmp_path / "bin" - fake_bin.mkdir() - calls = tmp_path / "calls" - pulls = tmp_path / "pulls" - pulls.write_text( - "\n".join(json.dumps(state) for state in pull_states) + "\n", - encoding="utf-8", - ) - fake_gh = fake_bin / "gh" - fake_gh.write_text( - """#!/usr/bin/env bash -set -euo pipefail -printf '%s\n' "$*" >>"$FAKE_CALLS" -if [[ "$*" == *"/pulls/7"* ]]; then - count_file="${FAKE_PULLS}.count" - count=0 - [[ ! -f "$count_file" ]] || count="$(cat "$count_file")" - count=$((count + 1)) - printf '%s' "$count" >"$count_file" - sed -n "${count}p" "$FAKE_PULLS" - exit 0 -fi -if [[ "$*" == *"actions/runs?status=queued"* ]]; then - printf '%s\n' '{"workflow_runs":[{"id":100,"name":"Strix Security Scan","event":"pull_request_target","pull_requests":[{"number":7,"head":{"sha":"old"}}]}]}' - exit 0 -fi -if [[ "$*" == *"actions/runs?status="* ]]; then - printf '%s\n' '{"workflow_runs":[]}' - exit 0 -fi -exit 0 -""", - encoding="utf-8", - ) - fake_gh.chmod(0o755) - env = { - **os.environ, - "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", - "FAKE_CALLS": str(calls), - "FAKE_PULLS": str(pulls), - "TARGET_REPOSITORY": "owner/repo", - "TARGET_PR_NUMBER": "7", - "TARGET_PR_HEAD_SHA": "current", - "PR_ACTION": "synchronize", - "CURRENT_RUN_ID": "999", - } - subprocess.run(["bash", "-c", script], env=env, check=True, capture_output=True, text=True) - return calls.read_text(encoding="utf-8") - - -def test_old_strix_cleanup_never_lists_or_cancels_after_live_head_advanced( - tmp_path: Path, -) -> None: - """A late old synchronize job must stop before selecting current runs.""" - calls = _run_strix_cleanup( - tmp_path, [{"state": "open", "head": {"sha": "newer"}}] * 5 - ) - - assert "actions/runs?status=" not in calls - assert "/cancel" not in calls - assert "/force-cancel" not in calls - - -def test_strix_cleanup_revalidates_after_selection_before_cancellation( - tmp_path: Path, -) -> None: - """A head advance after selection must prevent the pending mutation.""" - calls = _run_strix_cleanup( - tmp_path, - [ - {"state": "open", "head": {"sha": "current"}}, - {"state": "open", "head": {"sha": "newer"}}, - ] - + [{"state": "open", "head": {"sha": "newer"}}] * 4, - ) - - assert "actions/runs?status=queued" in calls - assert "/actions/runs/100/cancel" not in calls - assert "/actions/runs/100/force-cancel" not in calls - - def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() -> None: - """Close events should cancel old runs without starting expensive jobs.""" + """Close events retire stale work without launching Strix cleanup runners.""" workflows = ( "close-empty-pr.yml", "codeql-pr.yml", @@ -549,26 +399,11 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - assert "closed" in workflow if filename == "strix.yml": - assert "cancel-superseded-pr-runs:" in workflow - assert "Cancel queued and running scans for superseded or closed pull request heads" in workflow - assert ( - "secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN " - "|| github.token" - ) in workflow - assert "DISPATCH_REPOSITORY" not in workflow - assert "TARGET_PR_HEAD_SHA" in workflow - assert 'select(.event == "pull_request_target")' in workflow - assert 'select(.event == "repository_dispatch")' not in workflow - assert "(.pull_requests // [])" in workflow - assert ".head.sha // \"\"" in workflow - assert "leaving runs unchanged" in workflow - assert ( - "for active_status in queued in_progress requested waiting pending" - in workflow - ) - cleanup_job = workflow.split(" cancel-superseded-pr-runs:", 1)[1].split( - " strix:", 1 - )[0] + pre_jobs = workflow.split("jobs:", 1)[0] + assert "strix-workflow-${{" not in pre_jobs + assert "cancel-in-progress:" not in pre_jobs + assert "cancel-superseded-pr-runs:" not in workflow + assert "github.event.action != 'closed'" in workflow elif filename == "noema-review.yml": assert "cancel-closed-pr-runs:" in workflow assert "Cancel queued and running Noema reviews for the closed pull request" in workflow @@ -595,10 +430,13 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - assert "${{ secrets." not in opencode_bootstrap strix_workflow = workflow_text("strix.yml") - # Strix serializes scans per repository while cleanup stays outside that - # queue so synchronize and close events can immediately retire old work. - assert "cancel-in-progress: false" in strix_workflow + assert "cancel-in-progress: false" in strix_workflow.split(" strix:", 1)[1] assert "Keep provider-backed scans serial per repository" in strix_workflow + scheduler_workflow = workflow_text("pr-review-merge-scheduler.yml") + scheduler_scan_job = scheduler_workflow.split(" scan-pr-queue:", 1)[1].split( + "\n org-queue-sweep:", 1 + )[0] + assert "actions: write" in scheduler_scan_job def test_close_empty_pr_metadata_lookup_retries_and_fails_open() -> None: @@ -770,7 +608,7 @@ def test_strix_gateway_default_and_noema_sidecar_fail_closed( noema_script = textwrap.dedent( workflow_step( workflow_text("noema-review.yml"), - "Run Noema LLM review and submit verdict", + "Prepare Noema model verdict", ).split(" run: |\n", 1)[1] ) noema_env = { @@ -829,7 +667,6 @@ def test_noema_review_supports_review_token_pat_fallback() -> None: "Noema reviewer using the NOEMA_REVIEW_TOKEN secret fallback identity." in workflow ) - # The review step must prefer the PAT over the exchanged app token. assert ( "GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }}" in workflow @@ -993,25 +830,18 @@ def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: "ORG_SWEEP_UPDATE_BRANCHES", ): assert f"{setting}: ${{{{ github.event_name == 'schedule' ||" in workflow - # The single-repository scan must not double-run on the sweep cron. assert "github.event.schedule != '*/15 * * * *'" in workflow assert "github.event.client_payload.org_sweep != true" in workflow - # The sweep must never silently no-op with the repository-scoped token. assert ( "Organization queue sweep has no cross-repository mutation credential." in workflow ) assert 'select(.full_name != "ContextualWisdomLab/.github")' in workflow assert "select(.archived == false and .disabled == false)" in workflow - # The sweep must not silently truncate large/old queues or skip a repository - # whose only open work is a stacked/non-default-base PR. assert "vars.ORG_SWEEP_MAX_PRS || '1000'" in workflow assert "/pulls?state=open&per_page=1&base=" not in workflow assert "No open PRs (including stacked or non-default-base PRs)" in workflow - # Every repository failure must leave a concrete logged reason. assert "see the decision log above for the concrete per-PR reason" in workflow - # Queue hygiene: previous-head runs are cancelled immediately, while the - # legacy age guard cannot cancel a valid current-head PR run. assert "ORG_SWEEP_STALE_QUEUE_HOURS" in workflow assert "/actions/runs?status=${active_status}&per_page=100" in workflow assert "for active_status in queued in_progress" in workflow @@ -1025,9 +855,6 @@ def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: assert "Could not cancel superseded run" in workflow assert "No run will be cancelled from incomplete evidence" in workflow assert "queue_hygiene_ready=false" in workflow - # Organization sweep budgets must be consumed across the repository loop; - # resetting the configured limit for every target can flood Actions with - # long-running review dispatches. assert '"$ORG_SWEEP_REVIEW_DISPATCH_LIMIT" =~ ^(-1|[0-9]+)$' in workflow assert '"$ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT" =~ ^(-1|[0-9]+)$' in workflow assert '"$ORG_SWEEP_BRANCH_UPDATE_LIMIT" =~ ^(-1|[0-9]+)$' in workflow @@ -1043,9 +870,6 @@ def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: assert 'grep -Ec \'^PR #[0-9]+: (review_dispatch|security_dispatch):\'' in workflow assert 'grep -Ec \'^PR #[0-9]+: review_dispatch: stacked PR onto\'' in workflow assert 'grep -Ec \'^PR #[0-9]+: (update_branch|restamp_head):\'' in workflow - # The scheduler requires --project-flow; the sweep must derive and pass it - # per target repository (regression: the first sweep failed every repo with - # "--project-flow is required"). assert "--project-flow" in workflow assert 'main|master) project_flow="github-flow"' in workflow assert 'develop) project_flow="git-flow"' in workflow @@ -1107,8 +931,8 @@ def test_org_queue_sweep_rotation_offset_is_deterministic_and_reorders_targets() ("0", "repo-a"), ("1", "repo-b"), ("2", "repo-c"), - ("5", "repo-a"), # 5 % 5 == 0: wraps back to unrotated order - ("7", "repo-c"), # 7 % 5 == 2 + ("5", "repo-a"), + ("7", "repo-c"), ): script = ( "sweep_targets=($'repo-a\\tmain' $'repo-b\\tmain' $'repo-c\\tmain' " @@ -1244,7 +1068,7 @@ def test_org_queue_sweep_rotation_index_uses_persistent_counter_when_available( snippet, tmp_path, get_value="7", patch_ok=True, post_ok=True ) assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "8" # incremented by exactly one + assert result.stdout.strip() == "8" def test_org_queue_sweep_rotation_index_counter_increment_forces_base_10( @@ -1291,10 +1115,10 @@ def test_org_queue_sweep_rotation_index_falls_back_to_wall_clock(tmp_path: Path) ) assert result.returncode == 0, result.stderr stdout_lines = result.stdout.strip().splitlines() - computed_tick = int(stdout_lines[-1]) # last line: the printed value; earlier: the warning + computed_tick = int(stdout_lines[-1]) expected_tick = int(time.time()) // 900 - assert abs(computed_tick - expected_tick) <= 1 # tolerate a tick boundary race - assert "could not read/write" in result.stdout # a `::warning::` workflow command + assert abs(computed_tick - expected_tick) <= 1 + assert "could not read/write" in result.stdout def test_org_queue_sweep_rotation_index_transient_read_failure_does_not_reset_counter( @@ -1320,8 +1144,6 @@ def test_org_queue_sweep_rotation_index_transient_read_failure_does_not_reset_co computed_tick = int(stdout_lines[-1]) expected_tick = int(time.time()) // 900 assert abs(computed_tick - expected_tick) <= 1 - # Critically: never "1" -- that would mean the failed read was treated - # as a fresh-start reset rather than an unreadable existing value. assert stdout_lines[-1] != "1" @@ -1385,22 +1207,10 @@ def test_org_queue_sweep_documents_rotation_leverage_and_validates_input() -> No workflow = workflow_text("pr-review-merge-scheduler.yml") assert "ContextualWisdomLab/.github#1219" in workflow - assert ( - 'ORG_SWEEP_ROTATION_INDEX=$(( $(date -u +%s) / 900 ))' - ) in workflow - assert ( - 'if ! [[ "$ORG_SWEEP_ROTATION_INDEX" =~ ^[0-9]+$ ]]; then' - ) in workflow - assert ( - "rotation_offset=$(( ORG_SWEEP_ROTATION_INDEX % sweep_target_count ))" - ) in workflow - # `github.run_number` increments on every trigger of this workflow, not - # only the sweep schedule, so it cannot give the per-sweep-tick rotation - # guarantee the fix is meant to provide (ContextualWisdomLab/.github#1220 - # review finding). The env-block default must not reintroduce it. + assert 'ORG_SWEEP_ROTATION_INDEX=$(( $(date -u +%s) / 900 ))' in workflow + assert 'if ! [[ "$ORG_SWEEP_ROTATION_INDEX" =~ ^[0-9]+$ ]]; then' in workflow + assert "rotation_offset=$(( ORG_SWEEP_ROTATION_INDEX % sweep_target_count ))" in workflow assert "ORG_SWEEP_ROTATION_INDEX: ${{ github.run_number }}" not in workflow - # Keep ordinary and stacked review budgets independently configurable so - # ordinary work cannot starve the only review path for stacked PRs. assert "vars.ORG_SWEEP_REVIEW_DISPATCH_LIMIT || '1'" in workflow assert "vars.ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT || '1'" in workflow assert "Stacked PRs have no" in workflow @@ -1486,39 +1296,18 @@ def test_org_queue_sweep_active_run_aggregation_tolerates_error_payloads() -> No def test_org_queue_sweep_treats_inaccessible_repositories_as_non_fatal() -> None: - """A repository the sweep credential cannot read must not fail the sweep. - - When the OpenCode app is not installed on a sibling repository (or the - PR_REVIEW_MERGE_TOKEN does not cover it), every read returns HTTP 403 - "Resource not accessible by integration". That is an access-grant fact the - automation can never resolve, so those repositories are reported as skipped, - non-fatal "unavailable" repositories rather than hard failures — otherwise a - handful of un-enrolled repositories keeps the scheduled sweep (the - ``*/15 * * * *`` cron) permanently red and masks a genuinely new repository - that starts failing. - - The sweep stays fail-closed two ways: any non-403 scheduler failure still - increments ``failures`` and fails the job, and if MORE than - ``ORG_SWEEP_MAX_UNAVAILABLE`` repositories become unreachable at once (a - credential-scope regression, not a few un-enrolled repos) the job fails. - """ + """A repository the sweep credential cannot read must not fail the sweep.""" workflow = workflow_text("pr-review-merge-scheduler.yml") - # The 403 signal is classified as a skipped, non-fatal "unavailable" repo. assert "ORG_SWEEP_MAX_UNAVAILABLE" in workflow assert 'grep -qF "Resource not accessible by integration"' in workflow assert "unavailable=$((unavailable + 1))" in workflow assert 'unavailable_repos+=("$repo_full_name")' in workflow assert "the sweep credential lacks access (HTTP 403" in workflow - # A non-403 failure must still be a hard failure (fail-closed preserved). assert "failures=$((failures + 1))" in workflow assert "see the decision log above for the concrete per-PR reason" in workflow - # Widespread inaccessibility is a credential regression and must fail loudly. assert 'if [ "$unavailable" -gt "$ORG_SWEEP_MAX_UNAVAILABLE" ]; then' in workflow assert "indicates a credential-scope regression" in workflow - # The ceiling must be validated as a non-negative integer BEFORE the numeric - # test, or a misconfigured non-integer would make "[ -gt ]" error inside an - # if condition (which set -e does not trap) and silently skip the guard. assert '"$ORG_SWEEP_MAX_UNAVAILABLE" =~ ^[0-9]+$' in workflow assert "ORG_SWEEP_MAX_UNAVAILABLE must be a non-negative integer" in workflow @@ -1561,9 +1350,7 @@ def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> N ) assert "supported=false" not in workflow assert "skipping dependency-review hard gate" not in workflow - assert ( - "steps.dependency_review_support.outputs.supported == 'true'" in workflow - ) + assert "steps.dependency_review_support.outputs.supported == 'true'" in workflow dependency_review = workflow_step(workflow, "Dependency review") assert "comment-summary-in-pr: never" in dependency_review assert "comment-summary-in-pr: on-failure" not in dependency_review @@ -1629,9 +1416,7 @@ def test_security_scan_binds_every_scan_to_immutable_pr_revisions() -> None: "Upload Scorecard SARIF to code scanning", ): upload = workflow_step(workflow, upload_name) - assert ( - "ref: refs/pull/${{ github.event.pull_request.number }}/head" in upload - ) + assert "ref: refs/pull/${{ github.event.pull_request.number }}/head" in upload assert "sha: ${{ github.event.pull_request.head.sha }}" in upload @@ -1724,9 +1509,7 @@ def test_osv_pr_workflow_has_one_startup_safe_scan_args_block() -> None: ) -def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_failure() -> ( - None -): +def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_failure() -> None: """Retry OSV direct evidence without allowing transitive resolver stalls.""" workflow = workflow_text("security-scan.yml") @@ -1746,13 +1529,8 @@ def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_fai assert workflow.count("timeout-minutes: 4") == 2 assert workflow.count("\n --no-resolve\n") == 4 assert workflow.count("failed or timed out before reporter output was trusted") == 2 - assert ( - "Direct manifest and lockfile vulnerability evidence remains enforced" - in workflow - ) - assert ( - "external transitive registry resolution is intentionally avoided" in workflow - ) + assert "Direct manifest and lockfile vulnerability evidence remains enforced" in workflow + assert "external transitive registry resolution is intentionally avoided" in workflow assert ( "Retry base OSV without transitive resolution\n if: steps.osv_base.outcome == 'failure'\n continue-on-error: true" in workflow @@ -1977,9 +1755,7 @@ def test_strix_cross_repo_dispatch_uses_target_token_for_pr_scoping() -> None: ) not in run_step -def test_pr_scorecard_sarif_delegates_sast_and_vulnerability_posture_to_hard_gates() -> ( - None -): +def test_pr_scorecard_sarif_delegates_sast_and_vulnerability_posture_to_hard_gates() -> None: """PR Scorecard SARIF should not duplicate CodeQL/OSV/Trivy hard gates.""" for filename in ("scorecard-pr.yml", "security-scan.yml"): workflow = workflow_text(filename) @@ -2091,9 +1867,7 @@ def test_trivy_failure_log_prints_sarif_finding_details(tmp_path: Path) -> None: "locations": [ { "physicalLocation": { - "artifactLocation": { - "uri": "requirements.txt" - }, + "artifactLocation": {"uri": "requirements.txt"}, "region": {"startLine": 7}, } } diff --git a/tests/test_strix_control_plane_supersession.py b/tests/test_strix_control_plane_supersession.py new file mode 100644 index 0000000000..52a9effc96 --- /dev/null +++ b/tests/test_strix_control_plane_supersession.py @@ -0,0 +1,98 @@ +"""Regression contract for race-safe Strix predecessor-run supersession.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +STRIX_WORKFLOW = ROOT / ".github" / "workflows" / "strix.yml" + + +def _step(workflow: str, name: str) -> str: + """Return one named workflow step body without interpreting YAML.""" + marker = f" - name: {name}\n" + start = workflow.index(marker) + next_step = workflow.find("\n - name: ", start + len(marker)) + if next_step == -1: + return workflow[start:] + return workflow[start:next_step] + + +def test_strix_does_not_use_unordered_native_same_pr_cancellation() -> None: + """Delayed PR events must not be able to cancel a newer live-head scan.""" + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + pre_jobs = workflow.split("jobs:", 1)[0] + + assert "strix-workflow-${{" not in pre_jobs + assert "cancel-in-progress:" not in pre_jobs + assert "cancel-superseded-pr-runs:" not in workflow + + +def test_strix_validates_live_pr_before_expensive_setup() -> None: + """Reject stale pull_request_target evidence before provider setup starts.""" + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + early = _step(workflow, "Validate live pull request before Strix setup") + + assert workflow.index("Validate live pull request before Strix setup") < workflow.index( + "Set up Python" + ) + assert "if: github.event_name == 'pull_request_target'" in early + assert "GH_TOKEN: ${{ github.token }}" in early + assert "pull-requests: read" in workflow.split(" strix:", 1)[1].split(" steps:", 1)[0] + assert "if ! pull_request_json=" in early + assert "TARGET_REPOSITORY:" in early + assert "PR_NUMBER:" in early + assert "EXPECTED_HEAD_SHA:" in early + assert 'gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"' in early + assert ".state" in early + assert ".head.sha" in early + assert '"$live_state" != "open"' in early + assert '"$live_head_sha" != "$EXPECTED_HEAD_SHA"' in early + assert "exit 1" in early + + +def test_strix_revalidates_before_provider_execution() -> None: + """Close the runner-queue race before contextual-orchestrator work begins.""" + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + recheck = _step(workflow, "Revalidate live pull request before provider execution") + + assert workflow.index( + "Revalidate live pull request before provider execution" + ) < workflow.index("Provision contextual-orchestrator Strix sidecar") + assert 'gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"' in recheck + assert '"$live_state" != "open"' in recheck + assert '"$live_head_sha" != "$EXPECTED_HEAD_SHA"' in recheck + assert "exit 1" in recheck + + +def test_strix_revalidates_before_evidence_publication() -> None: + """A head/state change during scanning must not publish stale artifacts.""" + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + recheck = _step(workflow, "Revalidate live pull request before evidence publication") + + assert workflow.index( + "Revalidate live pull request before evidence publication" + ) < workflow.index("Collect Strix reports for artifact upload") + assert 'gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"' in recheck + assert '"$live_state" != "open"' in recheck + assert '"$live_head_sha" != "$EXPECTED_HEAD_SHA"' in recheck + assert "exit 1" in recheck + assert "id: live_publication" in recheck + assert "always() && github.event_name == 'pull_request_target'" in recheck + collect = _step(workflow, "Collect Strix reports for artifact upload") + upload = _step(workflow, "Upload Strix reports artifact") + assert "steps.live_publication.outputs.current == 'true'" in collect + assert "steps.live_publication.outputs.current == 'true'" in upload + + +def test_strix_preserves_provider_serialization_and_timeout_repair() -> None: + """Bound queued scans without regressing the current Strix timeout contract.""" + workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") + strix_job = workflow.split(" strix:", 1)[1] + concurrency = strix_job.split("concurrency:", 1)[1].split("runs-on:", 1)[0] + + assert "github.event.client_payload.target_repository" in concurrency + assert "github.event.pull_request.base.repo.full_name" in concurrency + assert "github.repository" in concurrency + assert "cancel-in-progress: false" in concurrency + assert "github.event.pull_request.number" not in concurrency + assert "export LLM_TIMEOUT=300" in workflow