diff --git a/.github/actions/noema-review/two_phase.py b/.github/actions/noema-review/two_phase.py index 4137556a96..2815d7a050 100755 --- a/.github/actions/noema-review/two_phase.py +++ b/.github/actions/noema-review/two_phase.py @@ -167,20 +167,16 @@ def prepare_verdict(repo: str, number: int, expected_head: str, path: Path) -> i 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 + verdict = gate.call_llm( + repo, + number, + pull_request, + diff, + truncated, + expected, + review_context, + changed_paths, + ) _write_envelope( path, diff --git a/.github/actions/orchestrator-free-sidecar/action.yml b/.github/actions/orchestrator-free-sidecar/action.yml new file mode 100644 index 0000000000..edddfe1bc3 --- /dev/null +++ b/.github/actions/orchestrator-free-sidecar/action.yml @@ -0,0 +1,40 @@ +name: Orchestrator free sidecar +description: Provision the immutable contextual-orchestrator orchestrator/free gateway for a model-backed workflow. +inputs: + require_zdr: + description: Require an attested Zero Data Retention route for private or internal content. + required: false + default: "false" + catalog_limit: + description: Maximum discovered route catalog size for the sidecar preflight (a candidate list probed lazily to a readiness target, ADR-0029). + required: false + default: "24" + catalog_account_cap: + description: Maximum routes admitted from one credential account. + required: false + default: "8" +runs: + using: composite + steps: + - name: Checkout immutable central sidecar source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ContextualWisdomLab/.github + ref: ${{ github.action_ref }} + path: ${{ runner.temp }}/cwl-control-plane + persist-credentials: false + - name: Provision contextual-orchestrator orchestrator/free + shell: bash --noprofile --norc -e -o pipefail {0} + env: + CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR: ${{ inputs.require_zdr }} + ORCHESTRATOR_CATALOG_LIMIT: ${{ inputs.catalog_limit }} + ORCHESTRATOR_CATALOG_ACCOUNT_CAP: ${{ inputs.catalog_account_cap }} + run: | + set -euo pipefail + control_plane="${RUNNER_TEMP}/cwl-control-plane" + sidecar="${control_plane}/scripts/ci/contextual_orchestrator_review_sidecar.sh" + if [ ! -f "$sidecar" ] || [ -L "$sidecar" ]; then + echo "::error::Immutable central contextual-orchestrator sidecar source is missing or symlinked." + exit 1 + fi + bash "$sidecar" diff --git a/.github/workflows/agent-mention-noema-dispatch.yml b/.github/workflows/agent-mention-noema-dispatch.yml index 4912e5addc..ad8abc7b25 100644 --- a/.github/workflows/agent-mention-noema-dispatch.yml +++ b/.github/workflows/agent-mention-noema-dispatch.yml @@ -9,9 +9,18 @@ on: types: [agent-mention-noema] concurrency: - group: agent-mention-noema-${{ github.event.client_payload.agent_invocation_key || github.run_id }} - cancel-in-progress: false - queue: max + # Workflow-level admission, for the same reason strix.yml, noema-review.yml, + # opencode-review.yml and opencode-review-dispatch.yml carry theirs at this level: + # a job-level group is never evaluated while the whole run waits behind the + # organization job ceiling, so a superseded mention keeps its queue slot until a + # runner frees up and only then cancels. At workflow level the older run is + # coalesced while both are still queued, which is where the slot is actually held. + # This workflow has a single job, so the group lives here and nowhere else -- + # every workflow in this repository that carries a group at both levels + # (strix.yml, opencode-review-dispatch.yml) gives the two levels DIFFERENT names, + # because a job requesting the group its own run already holds would wait on itself. + group: agent-mention-noema-${{ github.event.client_payload.target_repository }}-${{ github.event.client_payload.pr_number || github.run_id }} + cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/agent-mention-opencode-dispatch.yml b/.github/workflows/agent-mention-opencode-dispatch.yml index 5f6514221c..05461c9551 100644 --- a/.github/workflows/agent-mention-opencode-dispatch.yml +++ b/.github/workflows/agent-mention-opencode-dispatch.yml @@ -9,9 +9,18 @@ on: types: [agent-mention-opencode] concurrency: - group: agent-mention-opencode-${{ github.event.client_payload.agent_invocation_key || github.run_id }} - cancel-in-progress: false - queue: max + # Workflow-level admission, for the same reason strix.yml, noema-review.yml, + # opencode-review.yml and opencode-review-dispatch.yml carry theirs at this level: + # a job-level group is never evaluated while the whole run waits behind the + # organization job ceiling, so a superseded mention keeps its queue slot until a + # runner frees up and only then cancels. At workflow level the older run is + # coalesced while both are still queued, which is where the slot is actually held. + # This workflow has a single job, so the group lives here and nowhere else -- + # every workflow in this repository that carries a group at both levels + # (strix.yml, opencode-review-dispatch.yml) gives the two levels DIFFERENT names, + # because a job requesting the group its own run already holds would wait on itself. + group: agent-mention-opencode-${{ github.event.client_payload.target_repository }}-${{ github.event.client_payload.pr_number || github.run_id }} + cancel-in-progress: true permissions: contents: read diff --git a/.github/workflows/agent-mention-router-quality-ci.yml b/.github/workflows/agent-mention-router-quality-ci.yml index 14e924fc5d..9c36a89119 100644 --- a/.github/workflows/agent-mention-router-quality-ci.yml +++ b/.github/workflows/agent-mention-router-quality-ci.yml @@ -29,7 +29,7 @@ on: - "requirements-opencode-review-ci-hashes.txt" concurrency: - group: agent-mention-router-quality-${{ github.event.pull_request.number || github.ref }} + group: agent-mention-router-quality-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: diff --git a/.github/workflows/agent-mention-router.yml b/.github/workflows/agent-mention-router.yml index a109c8a97c..63ec8e3231 100644 --- a/.github/workflows/agent-mention-router.yml +++ b/.github/workflows/agent-mention-router.yml @@ -27,8 +27,8 @@ jobs: || contains(github.event.comment.body, '/oc') ) concurrency: - group: review-agent-mention-router-local-${{ github.repository }} - queue: max + group: review-agent-mention-router-local-${{ github.repository }}-${{ github.event.issue.number || github.run_id }} + cancel-in-progress: true runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: diff --git a/.github/workflows/agent-review-runtime-quality-ci.yml b/.github/workflows/agent-review-runtime-quality-ci.yml new file mode 100644 index 0000000000..3680da8778 --- /dev/null +++ b/.github/workflows/agent-review-runtime-quality-ci.yml @@ -0,0 +1,490 @@ +name: Agent Review Runtime Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/agent-review-runtime-quality-ci.yml" + - ".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" + - "tests/test_noema_refreshed_app_identity.py" + - "tests/test_noema_token_lifetime_stale_run_contract.py" + - "docs/doctoring/noema-review-token-lifetime.md" + - "docs/product-technical-gap-baseline.md" + - ".github/workflows/opencode-review-dispatch.yml" + - "scripts/ci/ensure_rust_llvm19.sh" + - "tests/test_opencode_rust_coverage_toolchain_contract.py" + - "tests/test_pr_review_autofix_nvidia_nim_contract.py" + - "docs/doctoring/opencode-rust-coverage-runtime-boundary.md" + - ".github/workflows/strix.yml" + - "docs/doctoring/strix-legal-git-paths.md" + - "docs/doctoring/strix-model-behavior-error.md" + - "docs/doctoring/strix-quality-timeout-fixtures.md" + - "scripts/ci/strix_quick_gate.sh" + - "scripts/ci/test_strix_quick_gate.sh" + - "tests/test_docs_only_pr_runner_admission.py" + - "tests/test_strix_changed_path_policy.py" + - "tests/test_strix_model_behavior_error.py" + - "tests/test_strix_nvidia_nim_not_found_fallback.py" + - "tests/test_strix_workflow_dependency_hashes.py" + - "tests/test_strix_quality_timeout_fixture_budget.py" + - "tests/test_agent_review_runtime_quality_consolidation.py" + - ".github/workflows/pr-review-merge-scheduler.yml" + - "scripts/ci/pr_review_merge_scheduler.py" + - "scripts/ci/pr_review_merge_scheduler_core.py" + - "tests/test_pr_review_merge_scheduler.py" + - "scripts/ci/current_head_run_coalescer.py" + - ".github/workflows/pr-review-fix-scheduler.yml" + - "scripts/ci/pr_review_fix_scheduler.py" + - ".github/workflows/pr-review-autofix.yml" + - ".github/workflows/hourly-review-repair.yml" + - "scripts/ci/pr_review_conflict_scope.py" + - "scripts/ci/pr_review_autofix_context.py" + - "scripts/ci/zdr_policy.py" + - "scripts/ci/contextual_orchestrator_review_policy.py" + - "scripts/ci/contextual_orchestrator_review_launcher.py" + - "scripts/ci/contextual_orchestrator_review_sidecar.sh" + - "tests/test_zdr_policy.py" + - "tests/test_contextual_orchestrator_review_policy.py" + - "tests/test_contextual_orchestrator_review_sidecar_contract.py" + - "tests/test_hourly_review_repair_callers.py" + - "tests/test_github_hourly_conflict_repair.py" + - "tests/test_hourly_scheduler_runtime_budget.py" + - "tests/test_hourly_autofix_context_quality_gate.py" + - "tests/test_pr_review_conflict_scope.py" + - "tests/test_pr_review_conflict_scope_control_files.py" + - "tests/test_pr_review_conflict_scope_git_executable.py" + - "tests/test_pr_review_conflict_scope_ignored_paths.py" + - "tests/test_pr_review_conflict_scope_symlink_targets.py" + - "tests/test_pr_review_fix_hourly_contract.py" + - "tests/test_pr_review_fix_scheduler.py" + - "tests/test_pr_review_fix_scheduler_source_pin.py" + - "tests/test_pr_review_autofix_context_head_binding.py" + - "tests/test_pr_review_autofix_nvidia_nim_contract.py" + - "tests/test_pr_review_autofix_writer_security_contract.py" + - "docs/automation/hourly-review-repair.md" + - "docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md" + - "docs/doctoring/contextual-orchestrator-vendored-sidecar.md" + - "docs/doctoring/hourly-review-repair-registry-retirement.md" + - "docs/doctoring/bandscope-hourly-review-caller.md" + - "docs/doctoring/clearfolio-hourly-review-caller.md" + - "docs/doctoring/conflict-control-evidence-isolation.md" + - "docs/doctoring/disksage-hourly-review-caller.md" + - "docs/doctoring/inkspan-hourly-review-caller.md" + - "docs/doctoring/lineageweave-hourly-review-caller.md" + - "docs/doctoring/fast-mlsirm-hourly-review-caller.md" + - "docs/doctoring/github-hourly-conflict-repair.md" + - "docs/doctoring/governance-risk-compliance-hourly-review-caller.md" + - "docs/doctoring/hourly-nvidia-nim-autofix.md" + - "docs/doctoring/nonnest2-hourly-review-caller.md" + - "docs/doctoring/orgmetra-hourly-review-caller.md" + - "docs/doctoring/originweave-hourly-review-caller.md" + - "docs/doctoring/quarantine-sandbox-hourly-review-caller.md" + - "docs/doctoring/contextual-orchestrator-hourly-review-caller.md" + - "docs/doctoring/afipc-hourly-review-caller.md" + - "docs/doctoring/review-repair-quality-workflow-identity.md" + - ".github/workflows/organization-commercial-readiness-loop.yml" + - ".github/workflows/exact-head-coverage-quality-gate.yml" + - "scripts/ci/organization_commercial_readiness_loop.py" + - "organization_commercial_readiness_fixtures.py" + - "tests/test_organization_commercial_readiness_loop*.py" + - "docs/doctoring/organization-commercial-readiness-loop.md" + - ".github/workflows/exact-artifact-sbom-attestation.yml" + - "scripts/ci/verify_exact_artifact_sbom_handoff.py" + - "tests/test_exact_artifact_sbom_attestation_contract.py" + - "tests/test_exact_artifact_sbom_review_regressions.py" + - "tests/test_verify_exact_artifact_sbom_handoff.py" + - "tests/test_exact_artifact_quality_single_runner.py" + - "docs/doctoring/exact-artifact-sbom-attestation.md" + - "docs/doctoring/exact-artifact-sbom-quality-runner-consolidation-20260903.md" + - "CHANGELOG.d/20260903-exact-artifact-quality-runner-consolidation.md" + - "requirements-opencode-review-ci-hashes.txt" + +# PR validation only: a new head cancels only an older run of this workflow +# for the same repository and pull request. +concurrency: + group: agent-review-runtime-quality-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + agent_review_runtime_quality: + name: agent-review-runtime-quality + runs-on: ubuntu-24.04 + timeout-minutes: 25 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact pull request head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Select affected contract suites + id: affected_suites + shell: bash --noprofile --norc -e -o pipefail {0} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + test "$(git rev-parse HEAD)" = "$HEAD_SHA" + noema_suite=false + opencode_suite=false + strix_suite=false + queue_suite=false + review_repair_suite=false + commercial_readiness_suite=false + exact_artifact_suite=false + + while IFS= read -r changed_path; do + case "$changed_path" in + .github/workflows/agent-review-runtime-quality-ci.yml) + noema_suite=true + opencode_suite=true + strix_suite=true + queue_suite=true + review_repair_suite=true + commercial_readiness_suite=true + exact_artifact_suite=true + ;; + tests/test_pr_review_autofix_nvidia_nim_contract.py) + opencode_suite=true + review_repair_suite=true + ;; + docs/product-technical-gap-baseline.md) + noema_suite=true + review_repair_suite=true + ;; + .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|\ + tests/test_noema_refreshed_app_identity.py|\ + tests/test_noema_token_lifetime_stale_run_contract.py|\ + docs/doctoring/noema-review-token-lifetime.md) + noema_suite=true + ;; + .github/workflows/opencode-review-dispatch.yml|\ + scripts/ci/ensure_rust_llvm19.sh|\ + tests/test_opencode_rust_coverage_toolchain_contract.py|\ + docs/doctoring/opencode-rust-coverage-runtime-boundary.md) + opencode_suite=true + ;; + .github/workflows/strix.yml|\ + docs/doctoring/strix-legal-git-paths.md|\ + docs/doctoring/strix-model-behavior-error.md|\ + docs/doctoring/strix-quality-timeout-fixtures.md|\ + scripts/ci/strix_quick_gate.sh|\ + scripts/ci/test_strix_quick_gate.sh|\ + tests/test_docs_only_pr_runner_admission.py|\ + tests/test_strix_changed_path_policy.py|\ + tests/test_strix_model_behavior_error.py|\ + tests/test_strix_nvidia_nim_not_found_fallback.py|\ + tests/test_strix_workflow_dependency_hashes.py|\ + tests/test_strix_quality_timeout_fixture_budget.py) + strix_suite=true + ;; + requirements-opencode-review-ci-hashes.txt) + noema_suite=true + opencode_suite=true + ;; + .github/workflows/pr-review-merge-scheduler.yml) + queue_suite=true + review_repair_suite=true + ;; + scripts/ci/current_head_run_coalescer.py) + queue_suite=true + ;; + .github/workflows/pr-review-fix-scheduler.yml|\ + scripts/ci/pr_review_fix_scheduler.py|\ + scripts/ci/pr_review_merge_scheduler.py|\ + scripts/ci/pr_review_merge_scheduler_core.py|\ + tests/test_pr_review_merge_scheduler.py|\ + .github/workflows/pr-review-autofix.yml|\ + .github/workflows/hourly-review-repair.yml|\ + scripts/ci/pr_review_conflict_scope.py|\ + scripts/ci/pr_review_autofix_context.py|\ + scripts/ci/zdr_policy.py|\ + scripts/ci/contextual_orchestrator_review_policy.py|\ + scripts/ci/contextual_orchestrator_review_launcher.py|\ + scripts/ci/contextual_orchestrator_review_sidecar.sh|\ + tests/test_zdr_policy.py|\ + tests/test_contextual_orchestrator_review_policy.py|\ + tests/test_contextual_orchestrator_review_sidecar_contract.py|\ + tests/test_hourly_review_repair_callers.py|\ + tests/test_github_hourly_conflict_repair.py|\ + tests/test_hourly_scheduler_runtime_budget.py|\ + tests/test_hourly_autofix_context_quality_gate.py|\ + tests/test_pr_review_conflict_scope.py|\ + tests/test_pr_review_conflict_scope_control_files.py|\ + tests/test_pr_review_conflict_scope_git_executable.py|\ + tests/test_pr_review_conflict_scope_ignored_paths.py|\ + tests/test_pr_review_conflict_scope_symlink_targets.py|\ + tests/test_pr_review_fix_hourly_contract.py|\ + tests/test_pr_review_fix_scheduler.py|\ + tests/test_pr_review_fix_scheduler_source_pin.py|\ + tests/test_pr_review_autofix_context_head_binding.py|\ + tests/test_pr_review_autofix_writer_security_contract.py|\ + docs/automation/hourly-review-repair.md|\ + docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md|\ + docs/doctoring/contextual-orchestrator-vendored-sidecar.md|\ + docs/doctoring/hourly-review-repair-registry-retirement.md|\ + docs/doctoring/bandscope-hourly-review-caller.md|\ + docs/doctoring/clearfolio-hourly-review-caller.md|\ + docs/doctoring/conflict-control-evidence-isolation.md|\ + docs/doctoring/disksage-hourly-review-caller.md|\ + docs/doctoring/inkspan-hourly-review-caller.md|\ + docs/doctoring/lineageweave-hourly-review-caller.md|\ + docs/doctoring/fast-mlsirm-hourly-review-caller.md|\ + docs/doctoring/github-hourly-conflict-repair.md|\ + docs/doctoring/governance-risk-compliance-hourly-review-caller.md|\ + docs/doctoring/hourly-nvidia-nim-autofix.md|\ + docs/doctoring/nonnest2-hourly-review-caller.md|\ + docs/doctoring/orgmetra-hourly-review-caller.md|\ + docs/doctoring/originweave-hourly-review-caller.md|\ + docs/doctoring/quarantine-sandbox-hourly-review-caller.md|\ + docs/doctoring/contextual-orchestrator-hourly-review-caller.md|\ + docs/doctoring/afipc-hourly-review-caller.md|\ + docs/doctoring/review-repair-quality-workflow-identity.md) + review_repair_suite=true + ;; + .github/workflows/organization-commercial-readiness-loop.yml|\ + .github/workflows/exact-head-coverage-quality-gate.yml|\ + scripts/ci/organization_commercial_readiness_loop.py|\ + organization_commercial_readiness_fixtures.py|\ + tests/test_organization_commercial_readiness_loop*.py|\ + docs/doctoring/organization-commercial-readiness-loop.md) + commercial_readiness_suite=true + ;; + .github/workflows/exact-artifact-sbom-attestation.yml|\ + scripts/ci/verify_exact_artifact_sbom_handoff.py|\ + tests/test_exact_artifact_sbom_attestation_contract.py|\ + tests/test_exact_artifact_sbom_review_regressions.py|\ + tests/test_verify_exact_artifact_sbom_handoff.py|\ + tests/test_exact_artifact_quality_single_runner.py|\ + docs/doctoring/exact-artifact-sbom-attestation.md|\ + docs/doctoring/exact-artifact-sbom-quality-runner-consolidation-20260903.md|\ + CHANGELOG.d/20260903-exact-artifact-quality-runner-consolidation.md) + exact_artifact_suite=true + ;; + esac + done < <(git diff --name-only "$BASE_SHA...$HEAD_SHA") + + { + echo "noema=$noema_suite" + echo "opencode=$opencode_suite" + echo "strix=$strix_suite" + echo "queue=$queue_suite" + echo "review_repair=$review_repair_suite" + echo "commercial_readiness=$commercial_readiness_suite" + echo "exact_artifact=$exact_artifact_suite" + } >>"$GITHUB_OUTPUT" + + - name: Install exact hash-verified base dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: "1" + PIP_NO_INPUT: "1" + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/strix-quality-requirements.txt" <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/strix-quality-requirements.txt" + + - name: Install exact review dependencies + if: steps.affected_suites.outputs.noema == 'true' || steps.affected_suites.outputs.opencode == 'true' || steps.affected_suites.outputs.review_repair == 'true' || steps.affected_suites.outputs.exact_artifact == 'true' + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Verify Noema token-lifetime contracts + if: steps.affected_suites.outputs.noema == 'true' + run: | + set -euo pipefail + PYTHONPATH=. python -m pytest -q \ + tests/test_noema_reviewer_token_lifetime.py \ + tests/test_noema_two_phase_handoff.py \ + tests/test_noema_refreshed_app_identity.py \ + tests/test_noema_token_lifetime_stale_run_contract.py + python -m compileall -q \ + .github/actions/noema-review/two_phase.py \ + tests/test_noema_reviewer_token_lifetime.py \ + tests/test_noema_two_phase_handoff.py \ + tests/test_noema_refreshed_app_identity.py \ + tests/test_noema_token_lifetime_stale_run_contract.py + + - name: Verify OpenCode Rust coverage toolchain contract + if: steps.affected_suites.outputs.opencode == 'true' + run: | + set -euo pipefail + python -m pytest -q tests/test_opencode_rust_coverage_toolchain_contract.py + python -m compileall -q tests/test_opencode_rust_coverage_toolchain_contract.py + + - name: Verify exact-head path policy and syntax + if: steps.affected_suites.outputs.strix == 'true' + env: + STRIX_TEST_PROCESS_TIMEOUT_SECONDS: "3" + STRIX_TEST_FAKE_SLEEP_SECONDS: "5" + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" + python -m pytest -q \ + tests/test_docs_only_pr_runner_admission.py \ + tests/test_strix_changed_path_policy.py \ + tests/test_strix_model_behavior_error.py \ + tests/test_strix_nvidia_nim_not_found_fallback.py \ + tests/test_strix_workflow_dependency_hashes.py \ + tests/test_strix_quality_timeout_fixture_budget.py + bash scripts/ci/test_strix_quick_gate.sh + python -m compileall -q \ + tests/test_strix_changed_path_policy.py \ + tests/test_strix_model_behavior_error.py \ + tests/test_strix_nvidia_nim_not_found_fallback.py \ + tests/test_strix_workflow_dependency_hashes.py \ + tests/test_strix_quality_timeout_fixture_budget.py + bash -n scripts/ci/strix_quick_gate.sh + + - name: Verify queue ownership contract + if: steps.affected_suites.outputs.queue == 'true' + run: | + set -euo pipefail + python -m pytest -q tests/test_current_head_coalescer_self_cancellation.py + python -m compileall -q tests/test_current_head_coalescer_self_cancellation.py + + - name: Verify scheduler and contextual-orchestrator review-repair contracts + if: steps.affected_suites.outputs.review_repair == 'true' + run: | + set -euo pipefail + python -m pytest -q \ + --cov=scripts.ci.pr_review_conflict_scope \ + --cov=scripts.ci.pr_review_autofix_context \ + --cov=scripts.ci.zdr_policy \ + --cov=scripts.ci.contextual_orchestrator_review_policy \ + --cov-branch \ + --cov-fail-under=100 + python -m interrogate --fail-under 100 \ + scripts/ci/pr_review_conflict_scope.py \ + scripts/ci/pr_review_autofix_context.py \ + scripts/ci/zdr_policy.py \ + scripts/ci/contextual_orchestrator_review_policy.py \ + scripts/ci/contextual_orchestrator_review_launcher.py + python -m compileall -q \ + scripts/ci/pr_review_conflict_scope.py \ + scripts/ci/pr_review_autofix_context.py \ + tests/test_pr_review_conflict_scope.py \ + scripts/ci/zdr_policy.py \ + scripts/ci/contextual_orchestrator_review_policy.py \ + scripts/ci/contextual_orchestrator_review_launcher.py \ + tests/test_zdr_policy.py \ + tests/test_contextual_orchestrator_review_policy.py \ + tests/test_contextual_orchestrator_review_sidecar_contract.py \ + tests/test_hourly_review_repair_callers.py \ + tests/test_github_hourly_conflict_repair.py \ + tests/test_hourly_scheduler_runtime_budget.py \ + tests/test_pr_review_conflict_scope_control_files.py \ + tests/test_hourly_autofix_context_quality_gate.py \ + tests/test_pr_review_conflict_scope_git_executable.py \ + tests/test_pr_review_conflict_scope_ignored_paths.py \ + tests/test_pr_review_conflict_scope_symlink_targets.py \ + tests/test_pr_review_fix_hourly_contract.py \ + tests/test_pr_review_fix_scheduler.py \ + tests/test_pr_review_fix_scheduler_source_pin.py \ + tests/test_pr_review_autofix_context_head_binding.py \ + tests/test_pr_review_autofix_nvidia_nim_contract.py \ + tests/test_pr_review_autofix_writer_security_contract.py + + - name: Verify organization commercial-readiness contracts + if: steps.affected_suites.outputs.commercial_readiness == 'true' + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + python -m coverage run \ + --branch \ + -m pytest --import-mode=importlib tests/test_organization_commercial_readiness_loop*.py -q + python -m coverage report \ + --include='scripts/ci/organization_commercial_readiness_loop.py' \ + --show-missing \ + --fail-under=100 + python -m compileall -q \ + scripts/ci/organization_commercial_readiness_loop.py \ + organization_commercial_readiness_fixtures.py \ + tests/test_organization_commercial_readiness_loop*.py + + - name: Set up minimum supported Python for exact-artifact contracts + if: steps.affected_suites.outputs.exact_artifact == 'true' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + + - name: Compile exact-artifact production and contracts on Python 3.10 + if: steps.affected_suites.outputs.exact_artifact == 'true' + run: | + python -m compileall -q \ + scripts/ci/verify_exact_artifact_sbom_handoff.py \ + tests/test_exact_artifact_sbom_attestation_contract.py \ + tests/test_exact_artifact_sbom_review_regressions.py \ + tests/test_verify_exact_artifact_sbom_handoff.py \ + tests/test_exact_artifact_quality_single_runner.py + + - name: Restore Python 3.14 for exact-artifact contracts + if: steps.affected_suites.outputs.exact_artifact == 'true' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Verify exact-artifact SBOM attestation contracts on Python 3.14 + if: steps.affected_suites.outputs.exact_artifact == 'true' + run: | + python -m coverage erase + python -m coverage run --branch -m pytest -q \ + tests/test_exact_artifact_sbom_attestation_contract.py \ + tests/test_exact_artifact_sbom_review_regressions.py \ + tests/test_verify_exact_artifact_sbom_handoff.py \ + tests/test_exact_artifact_quality_single_runner.py + python -m coverage report \ + --include=scripts/ci/verify_exact_artifact_sbom_handoff.py \ + --show-missing \ + --fail-under=100 + python -m interrogate --fail-under=100 \ + scripts/ci/verify_exact_artifact_sbom_handoff.py + python -m compileall -q \ + scripts/ci/verify_exact_artifact_sbom_handoff.py \ + tests/test_exact_artifact_sbom_attestation_contract.py \ + tests/test_exact_artifact_sbom_review_regressions.py \ + tests/test_verify_exact_artifact_sbom_handoff.py \ + tests/test_exact_artifact_quality_single_runner.py + + - name: Verify consolidated workflow contract + run: | + set -euo pipefail + python -m pytest -q tests/test_agent_review_runtime_quality_consolidation.py + python -m compileall -q tests/test_agent_review_runtime_quality_consolidation.py + git diff --check "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" + git diff --exit-code diff --git a/.github/workflows/audit-central-ruleset.yml b/.github/workflows/audit-central-ruleset.yml index ee93de9b06..2b72f21aab 100644 --- a/.github/workflows/audit-central-ruleset.yml +++ b/.github/workflows/audit-central-ruleset.yml @@ -10,14 +10,17 @@ on: paths: - ".github/workflows/audit-central-ruleset.yml" - "scripts/ci/audit_central_required_workflows.py" + - "scripts/ci/audit_org_codeql_coverage.py" + - "scripts/ci/bootstrap_codeql_pull_requests.py" - "docs/org-required-workflow-rollout.md" concurrency: - group: central-required-workflow-ruleset-audit + group: central-required-workflow-ruleset-audit-${{ github.event_name == 'repository_dispatch' && github.event.action || github.event_name }} cancel-in-progress: true permissions: contents: read + id-token: write jobs: audit: @@ -100,3 +103,150 @@ jobs: exit 1 fi python3 scripts/ci/audit_central_required_workflows.py --stacked "$stacked_ruleset_json" + + - name: Audit organization CodeQL coverage + # Runs even when the ruleset step above failed. Those two audits share a + # job but not a subject: the ruleset step exits 1 on owner-configured + # governance drift, and on 2026-09-06 it did exactly that ("exactly two + # approving reviews are not required", "last-push approval protection is + # disabled"), which silently took this CodeQL coverage detector down with + # it -- every run since 2026-09-04 failed there and never reached this + # step. This step builds its own repository list into its own temp file + # and the step above exports nothing to GITHUB_ENV or GITHUB_OUTPUT, so + # it has no data dependency to lose. The job still fails overall; what + # changes is that a coverage gap is reported instead of hidden behind an + # unrelated failure. + if: always() + env: + ORG_LOGIN: ContextualWisdomLab + ORG_WIDE_CREDENTIAL_AVAILABLE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '' }} + run: | + set -euo pipefail + + if [ "$ORG_WIDE_CREDENTIAL_AVAILABLE" = "false" ]; then + echo "::error::CodeQL coverage audit requires an org-scoped credential (PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN) to reliably enumerate private organization repositories; the repository-scoped github.token fallback cannot see them, which would silently narrow this audit to a subset of the organization." + exit 1 + fi + + repositories_json="$RUNNER_TEMP/codeql-coverage-organization-repositories.json" + coverage_json="$RUNNER_TEMP/codeql-coverage-repositories.json" + + if ! gh api --paginate "orgs/${ORG_LOGIN}/repos?type=all&per_page=100" \ + | jq -s 'add | map({name, archived}) | unique_by(.name) | sort_by(.name)' >"$repositories_json"; then + echo "::error::CodeQL coverage audit could not enumerate organization repositories for ${ORG_LOGIN}." + exit 1 + fi + + # ORG_WIDE_CREDENTIAL_AVAILABLE above only proves some org-scoped + # secret exists, not that the specific credential actually used + # (PR_REVIEW_MERGE_TOKEN when present) has complete repository + # visibility: docs/org-required-workflow-rollout.md's + # "Inaccessible-repository posture" entry already documents that + # PR_REVIEW_MERGE_TOKEN may be a fine-grained credential with an + # explicit repository allowlist rather than truly org-wide -- "a + # sibling repository the sweep credential structurally cannot + # read -- the OpenCode app is not installed there, or + # PR_REVIEW_MERGE_TOKEN does not cover it -- returns HTTP 403". + # That per-repo-read pattern doesn't apply here though: the + # enumeration call directly above IS the discovery mechanism, so a + # credential missing coverage does not 403 -- it just silently + # returns a smaller list, with excluded repositories never + # appearing at all and no per-repo error to catch. These three + # repositories are confirmed (2026-09-03, `gh api + # repos/ContextualWisdomLab/ --jq '{private,archived}'`) to + # be private and non-archived, so their absence from the + # enumerated list is real evidence of incomplete credential scope. + # If one is ever deleted, made public, or archived, swap in + # another confirmed private, non-archived repository here. + PRIVATE_REPOSITORY_COVERAGE_SENTINELS=( + "xtrmLLMBatchPython" + "linux-cluster-ops" + "gyeot" + ) + missing_sentinels=() + for sentinel in "${PRIVATE_REPOSITORY_COVERAGE_SENTINELS[@]}"; do + if ! jq -e --arg name "$sentinel" 'any(.[]; .name == $name)' "$repositories_json" >/dev/null; then + missing_sentinels+=("$sentinel") + fi + done + if [ "${#missing_sentinels[@]}" -gt 0 ]; then + echo "::error::CodeQL coverage audit's organization repository enumeration is missing known-private sentinel repository(ies): ${missing_sentinels[*]}. This means the credential used for this step cannot see the full organization -- PR_REVIEW_MERGE_TOKEN may be a fine-grained credential scoped to a repository allowlist rather than org-wide (see docs/org-required-workflow-rollout.md, 'Inaccessible-repository posture'). Unlike a per-repository 403, an incomplete-coverage credential does not fail this enumeration call; it silently returns a smaller repository list, so this audit would otherwise pass while covering only a subset of the organization. Fix the credential's scope/allowlist rather than ignoring this failure." + exit 1 + fi + + printf '[]\n' >"$coverage_json" + while IFS=$'\t' read -r repository archived; do + default_setup_state=null + # `state` alone is not coverage: a repository can report + # "configured" with an empty `languages` list, which scans nothing + # and produces no analyses (measured 2026-09-07 on life-os, aFIPC + # and inkspan). Collect both fields so the audit can tell those + # apart from a setup that actually covers a language. + default_setup_languages=null + if [ "$archived" != "true" ]; then + default_setup_json="$RUNNER_TEMP/codeql-default-setup-${repository//[^A-Za-z0-9_.-]/_}.json" + if gh api "repos/${ORG_LOGIN}/${repository}/code-scanning/default-setup" \ + >"$default_setup_json" 2>/dev/null; then + default_setup_state=$(jq '.state // null' "$default_setup_json") + default_setup_languages=$(jq '.languages // []' "$default_setup_json") + else + default_setup_state=null + default_setup_languages=null + fi + fi + + latest_codeql_analysis=null + if [ "$archived" != "true" ]; then + analysis_json="$RUNNER_TEMP/codeql-analysis-${repository//[^A-Za-z0-9_.-]/_}.json" + if gh api "repos/${ORG_LOGIN}/${repository}/code-scanning/analyses?tool_name=CodeQL&per_page=1" \ + --jq '.[0] | if . then {created_at, error} else null end' \ + >"$analysis_json" 2>/dev/null; then + latest_codeql_analysis=$(cat "$analysis_json") + else + latest_codeql_analysis=null + fi + fi + + echo "CODEQL_COVERAGE repository=${repository} archived=${archived} default_setup_state=${default_setup_state} default_setup_languages=${default_setup_languages} latest_codeql_analysis=${latest_codeql_analysis}" + jq --arg name "$repository" \ + --argjson archived "$archived" \ + --argjson default_setup_state "$default_setup_state" \ + --argjson default_setup_languages "$default_setup_languages" \ + --argjson latest_codeql_analysis "$latest_codeql_analysis" \ + '. + [{name: $name, archived: $archived, default_setup_state: $default_setup_state, default_setup_languages: $default_setup_languages, latest_codeql_analysis: $latest_codeql_analysis}]' \ + "$coverage_json" >"${coverage_json}.next" + mv "${coverage_json}.next" "$coverage_json" + done < <(jq -r '.[] | [.name, (.archived | tostring)] | @tsv' "$repositories_json") + + - name: Exchange OpenCode app token for CodeQL setup writes + id: opencode_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + request_url="$ACTIONS_ID_TOKEN_REQUEST_URL" + separator='&' + [[ "$request_url" == *\?* ]] || separator='?' + oidc_token="$(curl -fsS --connect-timeout 5 --max-time 20 \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" + [ -n "$oidc_token" ] || { echo "::error::OpenCode OIDC token was empty."; exit 1; } + app_token="$(curl -fsS --connect-timeout 5 --max-time 20 -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" + [ -n "$app_token" ] || { echo "::error::OpenCode installation token was empty."; exit 1; } + echo "::add-mask::$app_token" + { + echo "token<> "$GITHUB_OUTPUT" + + - name: Create missing CodeQL setup pull requests + env: + OPENCODE_APP_TOKEN: ${{ steps.opencode_app_token.outputs.token }} + run: | + set -euo pipefail + python3 scripts/ci/bootstrap_codeql_pull_requests.py "$RUNNER_TEMP/codeql-coverage-repositories.json" + python3 scripts/ci/audit_org_codeql_coverage.py "$RUNNER_TEMP/codeql-coverage-repositories.json" diff --git a/.github/workflows/close-empty-pr.yml b/.github/workflows/close-empty-pr.yml deleted file mode 100644 index d7e374c471..0000000000 --- a/.github/workflows/close-empty-pr.yml +++ /dev/null @@ -1,88 +0,0 @@ -# Auto-closes pull requests that have commits but no net change vs. their base -# (GitHub shows "No files changed / +0 -0"). The org's bot authors sometimes -# open such empty PRs; this closes them so humans do not have to. -# -# Runs per repo as a central required org workflow. pull_request_target gives a -# write-scoped token (needed to close) without checking out untrusted PR code, -# so there is no code-execution risk — the job only reads PR metadata and closes. -# Drafts are left alone. -name: Close Empty PR - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, closed] - -concurrency: - group: >- - close-empty-pr-${{ - github.event_name == 'pull_request_target' && github.event.pull_request.base.repo.full_name || github.repository }}-${{ - github.event_name == 'pull_request_target' && github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -permissions: - pull-requests: write - contents: read - -jobs: - close-empty: - if: github.event.action != 'closed' - runs-on: ubuntu-latest - steps: - - name: Close PR when it has no net changes - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.event.pull_request.base.repo.full_name }} - PR: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - gh_api_json_with_retry() { - local attempt output_file error_file - output_file="$(mktemp)" - error_file="$(mktemp)" - for attempt in 1 2 3 4; do - if gh api "$@" >"$output_file" 2>"$error_file" && jq -e type "$output_file" >/dev/null 2>&1; then - cat "$output_file" - rm -f "$output_file" "$error_file" - return 0 - fi - if [ "$attempt" -lt 4 ]; then - echo "GitHub API metadata request attempt ${attempt} did not return valid JSON; retrying." >&2 - cat "$error_file" >&2 || true - sleep $((attempt * 3)) - fi - done - echo "::warning::GitHub API metadata request did not return valid JSON after 4 attempts: gh api $*" >&2 - cat "$error_file" >&2 || true - rm -f "$output_file" "$error_file" - return 1 - } - - # GitHub computes the diff asynchronously; poll briefly for a settled - # changed_files count before deciding (null while still computing). - changed="" - draft="false" - for _ in 1 2 3 4 5 6; do - if ! payload="$(gh_api_json_with_retry "repos/${REPO}/pulls/${PR}")"; then - echo "PR #${PR} changed_files=unknown draft=${draft}; leaving it open because metadata could not be read." - exit 0 - fi - changed="$(jq -r '.changed_files // ""' <<<"$payload")" - draft="$(jq -r '.draft // false' <<<"$payload")" - [ -n "$changed" ] && break - sleep 10 - done - echo "PR #${PR} changed_files=${changed:-unknown} draft=${draft}" - - if [ "$draft" = "true" ]; then - echo "Draft PR — leaving it open." - exit 0 - fi - if [ "$changed" = "0" ]; then - gh pr comment "${PR}" --repo "${REPO}" \ - --body "자동 정리: base 대비 실제 변경(diff)이 0건이라 이 PR을 닫습니다. 변경을 추가한 뒤 reopen하세요." || true - gh pr close "${PR}" --repo "${REPO}" - echo "Closed empty PR #${PR}." - else - echo "PR has ${changed} changed file(s); leaving it open." - fi \ No newline at end of file diff --git a/.github/workflows/cloudflare-dns.yml b/.github/workflows/cloudflare-dns.yml index 0a54ff4022..991202f4e9 100644 --- a/.github/workflows/cloudflare-dns.yml +++ b/.github/workflows/cloudflare-dns.yml @@ -35,7 +35,7 @@ on: # the older validation run. Trusted push/dispatch reconciliation keeps its # non-cancelling behavior so an in-flight write is never interrupted midway. concurrency: - group: cloudflare-dns-${{ github.event.pull_request.number || github.ref }} + group: cloudflare-dns-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index 162aacf349..c21c8446df 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -1,15 +1,48 @@ -# Runs CodeQL on both the PR head and merge preview. Medium+ security results -# fail locally with rule/path/line/message evidence, while SARIF is preserved -# as an artifact. This keeps real findings blocking even when GitHub's -# installation API quota prevents code-scanning uploads. +# github/codeql-action cannot run inside a required workflow -- GitHub +# refuses to admit it, 0/43+ across every sampled repository +# (docs/doctoring/codeql-pr-required-workflow-always-fails.md). This file +# stays required-workflow-safe by never calling codeql-action itself: it +# detects languages, fails each analyze-head shard pending to release its +# runner, then one coordinator POSTs repository_dispatch to +# codeql-scan-dispatch.yml (native, unrestricted, in +# ContextualWisdomLab/.github) with the remaining language matrix. The +# handler publishes codeql-dispatch/ and reruns only that exact +# failed job. On rerun the shard reads the terminal status once. Design: +# docs/adr/0025-codeql-required-workflow-dispatch-architecture.md. The +# merge-preview scan (analyze-merge) is required nowhere (PR #1766) and was +# dropped, not migrated. name: CodeQL PR on: pull_request: types: [opened, synchronize, reopened, ready_for_review, closed] - branches: [main, master, develop] + # Do not restrict the base ref: the org required-workflow ruleset already + # scopes this to each repository's actual default branch via + # ref_name: ["~DEFAULT_BRANCH"], whatever it is named. A hardcoded + # [main, master, develop] list silently produced zero CodeQL checks for + # any repository with a different default branch name (confirmed live: + # a repository defaulting to gh-pages received every other required + # check but no CodeQL check at all) and would also block coverage for + # stacked PRs targeting a non-default feature branch, matching + # security-scan.yml's own "do not restrict the base ref" precedent. concurrency: + # NOT scoped by head SHA, unlike opencode-review.yml's group -- and that is + # a deliberate, tested difference, not an oversight. This file has no + # dedicated cancel-on-close cleanup job (see + # tests/test_required_workflow_queue_contract.py::test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs), + # so this group's own `cancel-in-progress: true` is the ONLY mechanism that + # cancels a stale in-flight run when the PR closes. opencode-review.yml can + # safely add head SHA to its group because it ALSO runs a separate + # cancel-superseded-opencode-review-runs job that sweeps stale runs via + # direct API calls regardless of head SHA; adding head SHA here without an + # equivalent job would let an older, still-in-flight run for a since- + # superseded head survive a close event indefinitely (it and the closing + # run would land in different groups and never cancel each other). A + # narrower risk remains -- a delayed dispatch for an older head could still + # transiently evict a newer head's in-flight dispatch before that older run's + # own live-head recheck self-aborts -- tracked as a follow-up requiring a + # dedicated cleanup job, not a one-line group change. group: >- codeql-pr-${{ github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name || github.repository }}-${{ @@ -23,9 +56,13 @@ jobs: detect-languages: name: Detect CodeQL languages if: github.event.action != 'closed' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: read outputs: matrix: ${{ steps.detect.outputs.matrix }} + code: ${{ steps.scope.outputs.code }} steps: - name: Checkout PR head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -60,215 +97,346 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" + - name: Classify changed paths + id: scope + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR: ${{ github.event.pull_request.number }} + EXPECTED_FILES: ${{ github.event.pull_request.changed_files }} + shell: bash + run: | + set -uo pipefail + code=true + if [ -n "${PR}" ] && [ -n "${EXPECTED_FILES}" ]; then + changed="" + for attempt in 1 2 3; do + if changed="$(gh api --paginate "repos/${REPO}/pulls/${PR}/files?per_page=100" --jq '.[].filename')" && [ -n "$changed" ]; then + break + fi + changed="" + sleep $((attempt * 3)) + done + # GitHub caps /pulls/N/files at 3000 entries; a short list would hide + # source files behind a doc-only verdict, so require an exact count. + if [ -n "$changed" ] && [ "$(printf '%s\n' "$changed" | wc -l | tr -d ' ')" = "${EXPECTED_FILES}" ]; then + code=false + while IFS= read -r changed_path; do + case "$changed_path" in + *.md|*.markdown|*.rst|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.bmp|*.ico|LICENSE|LICENSE.txt|COPYING|COPYING.txt|NOTICE|NOTICE.txt|.github/ISSUE_TEMPLATE/*) ;; + *) code=true ;; + esac + done <<<"$changed" + else + echo "::notice::changed-scope could not read a complete PR file list; scanning everything." + fi + fi + echo "code=${code}" >> "$GITHUB_OUTPUT" + echo "changed-scope code=${code}" + analyze-head: name: CodeQL compatibility analysis (${{ matrix.language }}) needs: detect-languages - runs-on: ubuntu-latest + # No job-level `if:` on purpose: a job-level condition referencing + # needs.detect-languages.outputs.* skips this job before its + # matrix-derived name is expanded, publishing the literal + # `CodeQL compatibility analysis (${{ matrix.language }})` check-run name + # instead of one per real language -- decisive live evidence in run + # 33708209086, guarded by + # tests/test_docs_only_pr_runner_admission.py::test_codeql_pr_gates_analyze_head_at_step_level_not_job_level. + # `needs: detect-languages` (only) matches the original, proven-safe + # dependency exactly; the only case where it's genuinely skipped is a + # closed PR, where this job being implicitly skipped too is fine because + # closed PRs need no required check. + runs-on: ubuntu-24.04 permissions: - actions: read contents: read - security-events: read + id-token: write strategy: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 - with: - egress-policy: audit - - - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha }} - - - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 - with: - category: "/language:${{ matrix.language }}" - upload: false - output: codeql-results-head - ref: ${{ format('refs/pull/{0}/head', github.event.pull_request.number) }} - sha: ${{ github.event.pull_request.head.sha }} - - - name: Enforce CodeQL Medium+ SARIF gate - shell: python3 {0} + - name: Read current-head CodeQL dispatch verdict + # Shards never dispatch. They re-check the live head, consume an + # authenticated codeql-dispatch/ verdict when one exists, + # and otherwise fail pending so the runner is released. One + # coordinator job POSTs the remaining language matrix after every + # shard has a job id. + id: dispatch + if: needs.detect-languages.outputs.code == 'true' env: - CODEQL_SARIF_DIR: codeql-results-head + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + LANGUAGE: ${{ matrix.language }} + RUN_ATTEMPT: ${{ github.run_attempt }} + REQUIRED_RUN_ID: ${{ github.run_id }} run: | - import json - import os - from pathlib import Path + set -euo pipefail + live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_head="$(printf '%s' "$live_pr" | jq -r '.head.sha // empty')" + live_base="$(printf '%s' "$live_pr" | jq -r '.base.sha // empty')" + live_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" + if [ -z "$live_head" ] || [ -z "$live_state" ]; then + echo "::error::Could not validate live pull request state before CodeQL dispatch." + exit 1 + fi + if [ "$live_state" = "closed" ]; then + echo "PR is closed on the live exact head; a current-head CodeQL scan is not requested." + exit 0 + fi + if [ "${live_head,,}" != "${PR_HEAD_SHA,,}" ]; then + echo "Pull request head moved on the live open PR; a fresh dispatch will fire for the current head." + exit 0 + fi + if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Could not validate live pull request base SHA before CodeQL verdict read." + exit 1 + fi + if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::CodeQL shard requires a canonical current run id." + exit 1 + fi - root = Path(os.environ["CODEQL_SARIF_DIR"]) - paths = sorted(root.rglob("*.sarif")) - if not paths: - raise SystemExit(f"CodeQL produced no SARIF under {root}; inspect the analysis log above.") + statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" + verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${LANGUAGE}" ' + [ + .[] + | select(.context == $ctx) + | select( + (.creator.login // "" | ascii_downcase) as $creator + | $creator == "opencode-agent" or $creator == "opencode-agent[bot]" + ) + ] + | first // {} | .state // empty + ')" + case "$verdict_state" in + success|failure|error) + echo "verdict=${verdict_state}" >>"$GITHUB_OUTPUT" + echo "Found authenticated current-head CodeQL verdict for ${LANGUAGE}: ${verdict_state}." + exit 0 + ;; + esac - findings = [] - total_results = 0 - for path in paths: - payload = json.loads(path.read_text(encoding="utf-8")) - for run in payload.get("runs") or []: - rules = ((run.get("tool") or {}).get("driver") or {}).get("rules") or [] - rules_by_id = { - str(rule.get("id") or ""): rule - for rule in rules - if isinstance(rule, dict) - } - for result in run.get("results") or []: - if not isinstance(result, dict): - continue - total_results += 1 - if result.get("suppressions"): - continue - rule = rules_by_id.get(str(result.get("ruleId") or ""), {}) - rule_index = result.get("ruleIndex") - if not rule and isinstance(rule_index, int) and 0 <= rule_index < len(rules): - rule = rules[rule_index] if isinstance(rules[rule_index], dict) else {} - result_properties = result.get("properties") or {} - rule_properties = rule.get("properties") or {} - raw_score = result_properties.get("security-severity", rule_properties.get("security-severity")) - try: - score = float(raw_score) - except (TypeError, ValueError): - score = None - level = str(result.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "none").lower() - tags = {str(tag).lower() for tag in rule_properties.get("tags") or []} - security_rule = "security" in tags or any(tag.startswith("external/cwe/") for tag in tags) - if not ((score is not None and score >= 4.0) or (score is None and security_rule and level in {"error", "warning"})): - continue - physical = (((result.get("locations") or [{}])[0].get("physicalLocation") or {})) - artifact = (physical.get("artifactLocation") or {}).get("uri") or "unknown" - line = (physical.get("region") or {}).get("startLine") or 0 - message = str((result.get("message") or {}).get("text") or "no message").replace("\n", " ") - findings.append((str(result.get("ruleId") or rule.get("id") or "unknown"), score, level, artifact, line, message)) + expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${PR_HEAD_SHA}/${live_base}/${REQUIRED_RUN_ID}" + expected_job="CodeQL dispatch scan (${LANGUAGE})" + runs_json="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/workflows/codeql-scan-dispatch.yml/runs")" + run_id="$(printf '%s' "$runs_json" | jq -r --arg title "$expected_title" --arg path ".github/workflows/codeql-scan-dispatch.yml" ' + [ + .[] | .workflow_runs[] + | select(.path == $path) + | select(.event == "repository_dispatch") + | select(.status == "completed") + | select(.display_title == $title or .name == $title) + ] + | first + | .id // empty + ')" + if [[ "$run_id" =~ ^[1-9][0-9]*$ ]]; then + jobs_json="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${run_id}/jobs")" + job_conclusion="$(printf '%s' "$jobs_json" | jq -r --arg name "$expected_job" ' + [.[] | .jobs[] | select(.name == $name)] + | if length == 1 then .[0].conclusion else empty end + ')" + case "$job_conclusion" in + success|failure) + echo "verdict=${job_conclusion}" >>"$GITHUB_OUTPUT" + echo "Found completed CodeQL dispatch scan job for ${LANGUAGE}: ${job_conclusion}." + exit 0 + ;; + esac + fi - print(f"CODEQL_SARIF files={len(paths)} results={total_results} medium_plus={len(findings)}") - for rule_id, score, level, artifact, line, message in findings: - severity = f"security-severity={score:g}" if score is not None else f"level={level}" - print(f"CODEQL_FINDING rule={rule_id} {severity} path={artifact} line={line} message={message}") - if findings: - raise SystemExit(f"CodeQL found {len(findings)} unsuppressed Medium+ security result(s).") + if [ "$RUN_ATTEMPT" != "1" ]; then + echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict." + exit 1 + fi + echo "verdict=pending" >>"$GITHUB_OUTPUT" - - name: Preserve CodeQL SARIF evidence - if: always() && hashFiles('codeql-results-head/**/*.sarif') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: codeql-head-${{ matrix.language }}-${{ github.run_id }}-${{ github.run_attempt }} - path: codeql-results-head - retention-days: 7 + - name: Release runner or enforce current-head CodeQL verdict + if: always() && needs.detect-languages.outputs.code == 'true' + env: + LANGUAGE: ${{ matrix.language }} + DISPATCH_OUTCOME: ${{ steps.dispatch.outcome }} + VERDICT_STATE: ${{ steps.dispatch.outputs.verdict }} + run: | + set -euo pipefail + if [ "$DISPATCH_OUTCOME" != "success" ]; then + echo "::error::CodeQL scan dispatch or exact-head verdict read did not succeed (outcome=${DISPATCH_OUTCOME})." + exit 1 + fi + case "$VERDICT_STATE" in + success) + echo "Current-head CodeQL dispatch verdict for ${LANGUAGE}: success." + ;; + failure|error) + echo "::error::CodeQL dispatch scan for ${LANGUAGE} did not pass (state=${VERDICT_STATE}). See the linked dispatch run for SARIF evidence." + exit 1 + ;; + pending) + echo "::error::CodeQL scan dispatched. The dispatch workflow will rerun this exact failed CodeQL job after publishing its terminal verdict." + exit 1 + ;; + *) + echo "::error::CodeQL shard has no authenticated current-head verdict or dispatch receipt." + exit 1 + ;; + esac - analyze-merge: - name: CodeQL merge preview (${{ matrix.language }}) - needs: detect-languages - if: github.event.action != 'closed' && github.event.pull_request.merge_commit_sha != '' - runs-on: ubuntu-latest + dispatch-current-head: + name: Dispatch current-head CodeQL scan + needs: [detect-languages, analyze-head] + if: >- + always() + && github.event.action != 'closed' + && github.event.pull_request.state != 'closed' + && needs.detect-languages.result == 'success' + && needs.detect-languages.outputs.code == 'true' + runs-on: ubuntu-24.04 permissions: - actions: read contents: read - security-events: read - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} + id-token: write + actions: read steps: - - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 - with: - egress-policy: audit - - - name: Checkout merge preview - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} - - - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 - with: - category: "/language:${{ matrix.language }}-merge" - upload: false - output: codeql-results-merge - ref: ${{ format('refs/pull/{0}/merge', github.event.pull_request.number) }} - sha: ${{ github.event.pull_request.merge_commit_sha }} - - - name: Enforce CodeQL Medium+ SARIF gate - shell: python3 {0} + - name: Dispatch current-head CodeQL scan env: - CODEQL_SARIF_DIR: codeql-results-merge + GH_TOKEN: ${{ github.token }} + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REQUIRED_RUN_ID: ${{ github.run_id }} + MATRIX: ${{ needs.detect-languages.outputs.matrix }} run: | - import json - import os - from pathlib import Path + set -euo pipefail + live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_head="$(printf '%s' "$live_pr" | jq -r '.head.sha // empty')" + live_base="$(printf '%s' "$live_pr" | jq -r '.base.sha // empty')" + live_base_ref="$(printf '%s' "$live_pr" | jq -r '.base.ref // empty')" + live_head_ref="$(printf '%s' "$live_pr" | jq -r '.head.ref // empty')" + live_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" + if [ -z "$live_head" ] || [ -z "$live_state" ]; then + echo "::error::Could not validate live pull request state before CodeQL dispatch." + exit 1 + fi + if [ "$live_state" = "closed" ]; then + echo "PR is closed on the live exact head; a current-head CodeQL scan is not requested." + exit 0 + fi + if [ "${live_head,,}" != "${PR_HEAD_SHA,,}" ]; then + echo "Pull request head moved on the live open PR; a fresh dispatch will fire for the current head." + exit 0 + fi + if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::CodeQL dispatch requires a canonical current run id." + exit 1 + fi + if ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]] || [ -z "$live_base_ref" ] || [ -z "$live_head_ref" ]; then + echo "::error::Could not validate live pull request base identity before CodeQL dispatch." + exit 1 + fi - root = Path(os.environ["CODEQL_SARIF_DIR"]) - paths = sorted(root.rglob("*.sarif")) - if not paths: - raise SystemExit(f"CodeQL produced no SARIF under {root}; inspect the analysis log above.") + include_json="$(printf '%s' "$MATRIX" | jq -c '.include // empty' 2>/dev/null || true)" + if [ -z "$include_json" ] || + [ "$(printf '%s' "$include_json" | jq 'type == "array" and length >= 1')" != "true" ]; then + echo "::error::CodeQL coordinator received an empty or malformed language matrix." + exit 1 + fi - findings = [] - total_results = 0 - for path in paths: - payload = json.loads(path.read_text(encoding="utf-8")) - for run in payload.get("runs") or []: - rules = ((run.get("tool") or {}).get("driver") or {}).get("rules") or [] - rules_by_id = { - str(rule.get("id") or ""): rule - for rule in rules - if isinstance(rule, dict) - } - for result in run.get("results") or []: - if not isinstance(result, dict): - continue - total_results += 1 - if result.get("suppressions"): - continue - rule = rules_by_id.get(str(result.get("ruleId") or ""), {}) - rule_index = result.get("ruleIndex") - if not rule and isinstance(rule_index, int) and 0 <= rule_index < len(rules): - rule = rules[rule_index] if isinstance(rules[rule_index], dict) else {} - result_properties = result.get("properties") or {} - rule_properties = rule.get("properties") or {} - raw_score = result_properties.get("security-severity", rule_properties.get("security-severity")) - try: - score = float(raw_score) - except (TypeError, ValueError): - score = None - level = str(result.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "none").lower() - tags = {str(tag).lower() for tag in rule_properties.get("tags") or []} - security_rule = "security" in tags or any(tag.startswith("external/cwe/") for tag in tags) - if not ((score is not None and score >= 4.0) or (score is None and security_rule and level in {"error", "warning"})): - continue - physical = (((result.get("locations") or [{}])[0].get("physicalLocation") or {})) - artifact = (physical.get("artifactLocation") or {}).get("uri") or "unknown" - line = (physical.get("region") or {}).get("startLine") or 0 - message = str((result.get("message") or {}).get("text") or "no message").replace("\n", " ") - findings.append((str(result.get("ruleId") or rule.get("id") or "unknown"), score, level, artifact, line, message)) + jobs_json="$( + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs" --jq '.jobs[]' | + jq -s '{jobs:.}' + )" + required_jobs='[]' + while IFS= read -r entry; do + language="$(printf '%s' "$entry" | jq -r '.language // empty')" + expected_name="CodeQL compatibility analysis (${language})" + job_id="$(printf '%s' "$jobs_json" | jq -r --arg name "$expected_name" ' + [.jobs[]? | select(.name == $name) | .id] + | if length == 1 then .[0] | tostring else empty end + ')" + if ! [[ "$job_id" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::CodeQL coordinator missing current-head job id for ${language}." + exit 1 + fi + required_jobs="$( + jq -c --arg language "$language" --argjson job_id "$job_id" \ + '. + [{language:$language,job_id:$job_id}]' <<<"$required_jobs" + )" + done < <(printf '%s' "$include_json" | jq -c '.[]') - print(f"CODEQL_SARIF files={len(paths)} results={total_results} medium_plus={len(findings)}") - for rule_id, score, level, artifact, line, message in findings: - severity = f"security-severity={score:g}" if score is not None else f"level={level}" - print(f"CODEQL_FINDING rule={rule_id} {severity} path={artifact} line={line} message={message}") - if findings: - raise SystemExit(f"CodeQL found {len(findings)} unsuppressed Medium+ security result(s).") + statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" + pending_matrix='[]' + while IFS= read -r entry; do + language="$(printf '%s' "$entry" | jq -r '.language // empty')" + verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${language}" ' + [ + .[] + | select(.context == $ctx) + | select( + (.creator.login // "" | ascii_downcase) as $creator + | $creator == "opencode-agent" or $creator == "opencode-agent[bot]" + ) + ] + | first // {} | .state // empty + ')" + case "$verdict_state" in + success|failure|error) + echo "Found authenticated current-head CodeQL verdict for ${language}: ${verdict_state}." + ;; + *) + pending_matrix="$(jq -c --argjson entry "$entry" '. + [$entry]' <<<"$pending_matrix")" + ;; + esac + done < <(printf '%s' "$include_json" | jq -c '.[]') - - name: Preserve CodeQL SARIF evidence - if: always() && hashFiles('codeql-results-merge/**/*.sarif') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: codeql-merge-${{ matrix.language }}-${{ github.run_id }}-${{ github.run_attempt }} - path: codeql-results-merge - retention-days: 7 + if [ "$(printf '%s' "$pending_matrix" | jq 'length')" -eq 0 ]; then + echo "All detected CodeQL languages already have authenticated terminal verdicts; skipping dispatch." + exit 0 + fi + + required_jobs="$( + jq -nc --argjson pending "$pending_matrix" --argjson jobs "$required_jobs" ' + ($pending | map(.language)) as $langs + | [$jobs[] | select(.language as $l | $langs | index($l) != null)] + ' + )" + if [ "$(printf '%s' "$required_jobs" | jq 'length')" != "$(printf '%s' "$pending_matrix" | jq 'length')" ]; then + echo "::error::CodeQL coordinator could not bind a job id to every pending language." + exit 1 + fi + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::CodeQL scan dispatch requires GitHub OIDC." + exit 1 + fi + separator='&' + [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] || separator='?' + oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" + if [ -z "$oidc_token" ]; then + echo "::error::CodeQL scan dispatch could not obtain its OIDC token." + exit 1 + fi + app_token="$(curl -fsS -X POST -H "Authorization: Bearer ${oidc_token}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" + if [ -z "$app_token" ]; then + echo "::error::CodeQL scan dispatch could not obtain its repository-scoped app token." + exit 1 + fi + echo "::add-mask::$app_token" + jq -cn \ + --arg target_repository "$TARGET_REPOSITORY" \ + --arg pr_number "$PR_NUMBER" \ + --arg pr_base_ref "$live_base_ref" \ + --arg pr_base_sha "$live_base" \ + --arg pr_head_ref "$live_head_ref" \ + --arg pr_head_sha "$live_head" \ + --argjson matrix "$pending_matrix" \ + --arg required_run_id "$REQUIRED_RUN_ID" \ + --argjson required_jobs "$required_jobs" \ + '{event_type:"codeql-scan",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head_ref:$pr_head_ref,pr_head_sha:$pr_head_sha,matrix:$matrix,required_run_id:$required_run_id,required_jobs:$required_jobs}}' | + GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input - diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml new file mode 100644 index 0000000000..c94fdf55c2 --- /dev/null +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -0,0 +1,584 @@ +# Runs github/codeql-action outside any required-workflow context. GitHub +# categorically refuses to admit init/analyze inside a required workflow +# (docs/doctoring/codeql-pr-required-workflow-always-fails.md); this file is +# the native execution half of the dispatch+exact-job-wake design implemented by +# ContextualWisdomLab/.github#1778. Do not add workflow_dispatch here to allow +# manual testing: +# test_no_central_workflow_exposes_branch_selected_manual_dispatch (in +# tests/test_required_workflow_queue_contract.py) forbids it on every central +# workflow, because workflow_dispatch runs the workflow file as it exists on +# whatever ref the caller selects rather than pinning to the default branch, +# defeating the trusted-source-ref pinning this design otherwise depends on. +# Exercise this handler end-to-end by POSTing a real repository_dispatch +# event instead -- that always runs the default-branch version. +name: CodeQL Scan Dispatch +run-name: >- + CodeQL Scan Dispatch ${{ github.event.client_payload.target_repository || + github.repository }}#${{ + github.event.client_payload.pr_number || 'event' }}@${{ + github.event.client_payload.pr_head_sha || github.sha }}/${{ + github.event.client_payload.pr_base_sha || 'none' }}/${{ + github.event.client_payload.required_run_id || github.run_id }} + +on: + repository_dispatch: + types: [codeql-scan] + +concurrency: + group: >- + codeql-scan-dispatch-${{ + github.event.client_payload.target_repository || github.repository }}-${{ + github.event.client_payload.pr_number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate-dispatch: + name: validate-dispatch + runs-on: ubuntu-24.04 + timeout-minutes: 8 + permissions: + contents: read + id-token: write + outputs: + target_repository: ${{ steps.validate.outputs.target_repository }} + pr_number: ${{ steps.validate.outputs.pr_number }} + base_ref: ${{ steps.validate.outputs.base_ref }} + base_sha: ${{ steps.validate.outputs.base_sha }} + head_ref: ${{ steps.validate.outputs.head_ref }} + head_sha: ${{ steps.validate.outputs.head_sha }} + matrix: ${{ steps.validate.outputs.matrix }} + required_run_id: ${{ steps.validate.outputs.required_run_id }} + required_jobs: ${{ steps.validate.outputs.required_jobs }} + steps: + - name: Exchange OpenCode app token for target repository metadata reads + id: metadata_read_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || + [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + + - name: Bind workflow inputs to live organization pull request metadata + id: validate + env: + GH_TOKEN: ${{ steps.metadata_read_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + # A rerun retains github.actor from the original dispatch; authorize + # the identity that initiated the current run or rerun instead. + # Reuses the same actor identity check as opencode-review-dispatch.yml + # (both mint their dispatching token via the same exchange endpoint), + # but deliberately does NOT reuse its OPENCODE_REPOSITORY_DISPATCH_TARGETS + # allowlist: that list scopes a deliberately gradual OpenCode review + # rollout to ~12 repos, whereas ruleset 18156473 (confirmed live via + # `gh api orgs/ContextualWisdomLab/rulesets/18156473`) covers + # ~ALL org repos except noema/.github/IRT-bibliography-set. Central + # CodeQL is meant to run for every one of those repos, not a curated + # subset -- reusing the narrower list would silently break CodeQL + # dispatch for every repo not already on the OpenCode rollout list. + # The org-membership regex below is the actual scope boundary here. + DISPATCH_ACTOR: ${{ github.triggering_actor }} + DISPATCH_SENDER: ${{ github.event.sender.login || '' }} + ALLOWED_DISPATCH_ACTOR: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_ACTOR }} + TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository }} + PR_NUMBER: ${{ github.event.client_payload.pr_number }} + SUPPLIED_BASE_REF: ${{ github.event.client_payload.pr_base_ref || '' }} + SUPPLIED_BASE_SHA: ${{ github.event.client_payload.pr_base_sha || '' }} + SUPPLIED_HEAD_REF: ${{ github.event.client_payload.pr_head_ref || '' }} + SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }} + SUPPLIED_MATRIX: ${{ toJSON(github.event.client_payload.matrix) }} + SUPPLIED_REQUIRED_RUN_ID: ${{ github.event.client_payload.required_run_id || '' }} + SUPPLIED_REQUIRED_JOBS: ${{ toJSON(github.event.client_payload.required_jobs) }} + # Pre-#2008 payloads still send scalar required_job_id + + # required_language with a one-shard matrix. Synthesize + # required_jobs from those only when the array is empty. + SUPPLIED_REQUIRED_JOB_ID: ${{ github.event.client_payload.required_job_id || '' }} + SUPPLIED_REQUIRED_LANGUAGE: ${{ github.event.client_payload.required_language || '' }} + run: | + set -euo pipefail + # ALLOWED_DISPATCH_ACTOR is a comma-separated allowlist shared with + # opencode-review-dispatch.yml and pr-review-fix-scheduler.yml; all + # three parse it the same way. Actor AND sender must both equal the + # SAME listed identity, and an empty allowlist admits nothing. + actor_allowed=0 + IFS=',' read -r -a allowed_dispatch_actors <<<"$ALLOWED_DISPATCH_ACTOR" + for allowed_actor in "${allowed_dispatch_actors[@]}"; do + allowed_actor="${allowed_actor//[[:space:]]/}" + if [ -n "$allowed_actor" ] && + [ "$DISPATCH_ACTOR" = "$allowed_actor" ] && + [ "$DISPATCH_SENDER" = "$allowed_actor" ]; then + actor_allowed=1 + break + fi + done + if [ "$actor_allowed" -ne 1 ]; then + printf '::error::repository_dispatch authorization rejected actor=%s sender=%s because both must match one configured scheduler identity.\n' "${DISPATCH_ACTOR:-}" "${DISPATCH_SENDER:-}" + exit 1 + fi + printf 'Authorized repository_dispatch actor=%s sender=%s target=%s.\n' "$DISPATCH_ACTOR" "$DISPATCH_SENDER" "$TARGET_REPOSITORY" + + if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + printf '::error::PR metadata validation rejected a target outside ContextualWisdomLab or an invalid pull request number. target=%s pr=%s\n' "${TARGET_REPOSITORY:-}" "${PR_NUMBER:-}" + exit 1 + fi + + matrix_json="$(printf '%s' "$SUPPLIED_MATRIX" | jq -c '.' 2>/dev/null || true)" + jobs_json="$(printf '%s' "$SUPPLIED_REQUIRED_JOBS" | jq -c '.' 2>/dev/null || true)" + if [ -z "$matrix_json" ] || + [ "$(printf '%s' "$matrix_json" | jq 'type == "array" and length >= 1')" != "true" ] || + [ "$(printf '%s' "$matrix_json" | jq '[.[] | select((.language | type == "string") and (.language | test("^[a-z0-9-]+$")) and (."build-mode" | type == "string"))] | length == ($ARGS.positional[0] | tonumber)' --args "$(printf '%s' "$matrix_json" | jq 'length')")" != "true" ] || + [ "$(printf '%s' "$matrix_json" | jq '(map(.language) | unique | length) == (map(.language) | length)')" != "true" ]; then + printf '::error::CodeQL scan dispatch matrix must contain at least one valid language/build-mode shard with unique languages. matrix=%s\n' "${SUPPLIED_MATRIX:-}" + exit 1 + fi + if [ -z "$jobs_json" ] || + [ "$(printf '%s' "$jobs_json" | jq '(. == null) or (. == [])')" = "true" ]; then + if [ "$(printf '%s' "$matrix_json" | jq 'type == "array" and length == 1')" = "true" ] && + [[ "$SUPPLIED_REQUIRED_JOB_ID" =~ ^[1-9][0-9]*$ ]] && + [ "$SUPPLIED_REQUIRED_LANGUAGE" = "$(printf '%s' "$matrix_json" | jq -r '.[0].language // empty')" ]; then + jobs_json="$(jq -nc --arg language "$SUPPLIED_REQUIRED_LANGUAGE" --arg job_id "$SUPPLIED_REQUIRED_JOB_ID" '[{language: $language, job_id: ($job_id | tonumber)}]')" + fi + fi + if [ -z "$jobs_json" ] || + [ "$(jq -n --argjson matrix "$matrix_json" --argjson jobs "$jobs_json" ' + ($jobs | type == "array") + and (($jobs | length) == ($matrix | length)) + and ($jobs | all( + (.language | type == "string") + and (.language | test("^[a-z0-9-]+$")) + and ( + ((.job_id | type == "number") and (.job_id == (.job_id | floor)) and (.job_id >= 1)) + or ((.job_id | type == "string") and (.job_id | test("^[1-9][0-9]*$"))) + ) + )) + and (($jobs | map(.language) | sort) == ($matrix | map(.language) | sort)) + and (($jobs | map(.language) | unique | length) == ($jobs | length)) + ')" != "true" ]; then + printf '::error::CodeQL wake identity is missing, non-canonical, or does not match the dispatched languages one-to-one.\n' + exit 1 + fi + if ! [[ "$SUPPLIED_REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + printf '::error::CodeQL wake identity is missing, non-canonical, or does not match the dispatched languages one-to-one.\n' + exit 1 + fi + jobs_json="$(printf '%s' "$jobs_json" | jq -c 'map({language, job_id: (.job_id | tonumber)})')" + + pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" + live_head_repository="$(jq -r '.head.repo.full_name // empty' <<<"$pull_request_json")" + live_base_ref="$(jq -r '.base.ref // empty' <<<"$pull_request_json")" + live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_request_json")" + live_head_ref="$(jq -r '.head.ref // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + + if [ "$live_state" != "open" ] || + [ "$live_base_repository" != "$TARGET_REPOSITORY" ] || + [ "$live_head_repository" != "$TARGET_REPOSITORY" ] || + ! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "$live_head_sha" =~ ^[0-9a-fA-F]{40}$ ]] || + [ -z "$live_base_ref" ] || + [ -z "$live_head_ref" ]; then + printf '::error::PR metadata validation rejected closed, missing, cross-fork, or malformed live metadata. target=%s#%s state=%s base_repo=%s head_repo=%s base=%s head=%s\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "${live_state:-}" "${live_base_repository:-}" "${live_head_repository:-}" "${live_base_sha:-}" "${live_head_sha:-}" + exit 1 + fi + + mismatches=() + [ "$SUPPLIED_BASE_REF" = "$live_base_ref" ] || mismatches+=("base_ref") + [ "$SUPPLIED_BASE_SHA" = "$live_base_sha" ] || mismatches+=("base_sha") + [ "$SUPPLIED_HEAD_REF" = "$live_head_ref" ] || mismatches+=("head_ref") + [ "$SUPPLIED_HEAD_SHA" = "$live_head_sha" ] || mismatches+=("head_sha") + if [ "${#mismatches[@]}" -gt 0 ]; then + printf '::error::repository_dispatch metadata does not match the live pull request: %s. supplied_base=%s/%s live_base=%s/%s supplied_head=%s/%s live_head=%s/%s\n' "$(IFS=,; printf '%s' "${mismatches[*]}")" "${SUPPLIED_BASE_REF:-}" "${SUPPLIED_BASE_SHA:-}" "$live_base_ref" "$live_base_sha" "${SUPPLIED_HEAD_REF:-}" "${SUPPLIED_HEAD_SHA:-}" "$live_head_ref" "$live_head_sha" + exit 1 + fi + + { + printf 'target_repository=%s\n' "$TARGET_REPOSITORY" + printf 'pr_number=%s\n' "$PR_NUMBER" + printf 'base_ref=%s\n' "$live_base_ref" + printf 'base_sha=%s\n' "$live_base_sha" + printf 'head_ref=%s\n' "$live_head_ref" + printf 'head_sha=%s\n' "$live_head_sha" + echo "matrix<>"$GITHUB_OUTPUT" + printf 'Validated current live metadata for %s#%s: base=%s/%s head=%s/%s.\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "$live_base_ref" "$live_base_sha" "$live_head_ref" "$live_head_sha" + + scan: + name: CodeQL dispatch scan (${{ matrix.language }}) + needs: validate-dispatch + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + actions: write + contents: read + security-events: read + id-token: write + statuses: write # Required for downscoped OIDC status publication. + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.validate-dispatch.outputs.matrix) }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + + - name: Exchange OpenCode app token for target repository content reads + id: target_app_token + env: + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + + mark_unavailable() { + echo "available=false" >>"$GITHUB_OUTPUT" + } + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OpenCode app token exchange unavailable: OIDC request environment is missing." + mark_unavailable + exit 0 + fi + + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" + separator="&" + case "$request_url" in + *\?*) ;; + *) separator="?" ;; + esac + + if ! oidc_response="$( + curl -fsS \ + -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${request_url}${separator}audience=${OIDC_AUDIENCE}" + )"; then + echo "OpenCode app token exchange unavailable: OIDC token request did not complete." + mark_unavailable + exit 0 + fi + + oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" + if [ -z "$oidc_token" ]; then + echo "OpenCode app token exchange unavailable: OIDC token response was empty." + mark_unavailable + exit 0 + fi + + if ! token_response="$( + curl -fsS \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + "${OPENCODE_API_BASE_URL}/exchange_github_app_token" + )"; then + echo "OpenCode app token exchange unavailable: app token request did not complete." + mark_unavailable + exit 0 + fi + + app_token="$(jq -r '.token // empty' <<<"$token_response")" + if [ -z "$app_token" ]; then + echo "OpenCode app token exchange unavailable: app token response was empty." + mark_unavailable + exit 0 + fi + + echo "::add-mask::$app_token" + { + echo "available=true" + echo "token=$app_token" + } >>"$GITHUB_OUTPUT" + + - name: Re-validate live pull request metadata before privileged scan + env: + GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} + PR_NUMBER: ${{ needs.validate-dispatch.outputs.pr_number }} + EXPECTED_BASE_REF: ${{ needs.validate-dispatch.outputs.base_ref }} + EXPECTED_BASE_SHA: ${{ needs.validate-dispatch.outputs.base_sha }} + EXPECTED_HEAD_REF: ${{ needs.validate-dispatch.outputs.head_ref }} + EXPECTED_HEAD_SHA: ${{ needs.validate-dispatch.outputs.head_sha }} + run: | + set -euo pipefail + pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" + live_base_ref="$(jq -r '.base.ref // empty' <<<"$pull_request_json")" + live_base_sha="$(jq -r '.base.sha // empty' <<<"$pull_request_json")" + live_head_ref="$(jq -r '.head.ref // empty' <<<"$pull_request_json")" + live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" + if [ "$live_state" != "open" ] || + [ "$live_base_ref" != "$EXPECTED_BASE_REF" ] || + [ "$live_base_sha" != "$EXPECTED_BASE_SHA" ] || + [ "$live_head_ref" != "$EXPECTED_HEAD_REF" ] || + [ "$live_head_sha" != "$EXPECTED_HEAD_SHA" ]; then + printf '::error::CodeQL scan dispatch metadata changed between validation and scan for %s#%s; retiring this superseded run.\n' "$TARGET_REPOSITORY" "$PR_NUMBER" + exit 1 + fi + + - name: Fetch the pinned CodeQL SARIF gate script + env: + GH_TOKEN: ${{ github.token }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + gh api "repos/ContextualWisdomLab/.github/contents/scripts/ci/codeql_sarif_gate.py?ref=${WORKFLOW_SHA}" \ + --jq .content | base64 --decode >"$RUNNER_TEMP/codeql_sarif_gate.py" + python3 -c "import ast; ast.parse(open('$RUNNER_TEMP/codeql_sarif_gate.py').read())" + + - name: Materialize pull request head for CodeQL scan + env: + GH_TOKEN: ${{ steps.target_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} + HEAD_SHA: ${{ needs.validate-dispatch.outputs.head_sha }} + run: | + set -euo pipefail + gh auth setup-git + git init -q . + git remote add origin "$GITHUB_SERVER_URL/$TARGET_REPOSITORY.git" + git fetch --no-tags --depth=1 origin "$HEAD_SHA" + git checkout --detach --quiet "$HEAD_SHA" + git cat-file -e "$HEAD_SHA^{commit}" + + - name: Initialize CodeQL + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + category: "/language:${{ matrix.language }}" + upload: false + output: codeql-results-dispatch + ref: ${{ needs.validate-dispatch.outputs.head_ref }} + sha: ${{ needs.validate-dispatch.outputs.head_sha }} + + - name: Enforce CodeQL Medium+ SARIF gate + id: gate + run: python3 "$RUNNER_TEMP/codeql_sarif_gate.py" codeql-results-dispatch + + - name: Preserve CodeQL SARIF evidence + if: always() && hashFiles('codeql-results-dispatch/**/*.sarif') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: codeql-dispatch-${{ matrix.language }}-${{ github.run_id }}-${{ github.run_attempt }} + path: codeql-results-dispatch + retention-days: 7 + + - name: Publish CodeQL dispatch status + id: publish_status + if: always() + env: + TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} + GITHUB_STATUS_READ_TOKEN: ${{ github.token }} + PR_REVIEW_MERGE_STATUS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} + OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} + TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} + HEAD_SHA: ${{ needs.validate-dispatch.outputs.head_sha }} + LANGUAGE: ${{ matrix.language }} + GATE_OUTCOME: ${{ steps.gate.outcome }} + run: | + set -euo pipefail + case "$GATE_OUTCOME" in + success) + state="success" + description="CodeQL dispatch scan passed (no unsuppressed Medium+ findings)" + ;; + failure) + state="failure" + description="CodeQL dispatch scan found unsuppressed Medium+ findings" + ;; + *) + state="error" + description="CodeQL dispatch scan did not produce a verdict (${GATE_OUTCOME:-unknown})" + ;; + esac + + post_status() { + token_label="$1" + token="$2" + if [ -z "$token" ]; then + return 1 + fi + status_response="$(mktemp)" + status_error="$(mktemp)" + if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/statuses/${HEAD_SHA}" \ + -f state="$state" \ + -f context="codeql-dispatch/${LANGUAGE}" \ + -f description="$description" \ + -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + >"$status_response" 2>"$status_error"; then + rm -f "$status_response" "$status_error" + echo "Published CodeQL dispatch status to ${TARGET_REPOSITORY}@${HEAD_SHA} using ${token_label}." + return 0 + fi + error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" + rm -f "$status_response" "$status_error" + if [ -n "$error_summary" ]; then + echo "::notice::CodeQL dispatch status publish using ${token_label} did not succeed: ${error_summary}" + else + echo "::notice::CodeQL dispatch status publish using ${token_label} did not succeed." + fi + return 1 + } + + if post_status "target-app-token" "$TARGET_APP_STATUS_TOKEN"; then + exit 0 + fi + if post_status "pr-review-merge-token" "$PR_REVIEW_MERGE_STATUS_TOKEN"; then + exit 0 + fi + if post_status "opencode-approve-token" "$OPENCODE_APPROVE_STATUS_TOKEN"; then + exit 0 + fi + if post_status "github-token" "$GITHUB_STATUS_READ_TOKEN"; then + exit 0 + fi + + if [ "$GATE_OUTCOME" = "success" ]; then + echo "::notice::Could not publish the CodeQL dispatch status after all configured credentials failed. The completed dispatch scan job remains the evidence for this head." + exit 0 + fi + + echo "::error::Could not publish the CodeQL dispatch status after all configured credentials failed; the exact required job will remain failed and will not be woken with stale or missing evidence." + exit 1 + + - name: Wake exact CodeQL required job + if: >- + always() + && steps.publish_status.outcome == 'success' + && needs.validate-dispatch.outputs.target_repository != '' + && needs.validate-dispatch.outputs.pr_number != '' + && needs.validate-dispatch.outputs.head_sha != '' + && needs.validate-dispatch.outputs.required_run_id != '' + && needs.validate-dispatch.outputs.required_jobs != '' + env: + GH_TOKEN: ${{ needs.validate-dispatch.outputs.target_repository == github.repository && github.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} + TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} + PR_NUMBER: ${{ needs.validate-dispatch.outputs.pr_number }} + HEAD_SHA: ${{ needs.validate-dispatch.outputs.head_sha }} + REQUIRED_RUN_ID: ${{ needs.validate-dispatch.outputs.required_run_id }} + REQUIRED_JOBS: ${{ needs.validate-dispatch.outputs.required_jobs }} + REQUIRED_LANGUAGE: ${{ matrix.language }} + WAKE_TOKEN_SOURCE: ${{ needs.validate-dispatch.outputs.target_repository == github.repository && 'github-token' || secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'unavailable' }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN:-}" ] || [ "$WAKE_TOKEN_SOURCE" = "unavailable" ]; then + echo "::error::Actions-capable CodeQL wake credential is unavailable." + exit 1 + fi + REQUIRED_JOB_ID="$(printf '%s' "$REQUIRED_JOBS" | jq -r --arg lang "$REQUIRED_LANGUAGE" ' + [.[] | select(.language == $lang) | .job_id | tostring] + | if length == 1 and (.[0] | test("^[1-9][0-9]*$")) then .[0] else empty end + ')" + if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$REQUIRED_JOB_ID" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$REQUIRED_LANGUAGE" =~ ^[a-z0-9-]+$ ]]; then + echo "::error::CodeQL wake identity is non-canonical." + exit 1 + fi + + pull="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_state="$(printf '%s' "$pull" | jq -r '.state // empty')" + live_head="$(printf '%s' "$pull" | jq -r '.head.sha // empty')" + if [ "$live_state" != "open" ] || [ "$live_head" != "$HEAD_SHA" ]; then + echo "::error::CodeQL wake rejected a closed PR or stale head." + exit 1 + fi + + run="$(gh api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}")" + run_identity="$(printf '%s' "$run" | jq -r --arg head "$HEAD_SHA" --argjson run_id "$REQUIRED_RUN_ID" ' + select(.id == $run_id) + | select(.event == "pull_request") + | select(.path == ".github/workflows/codeql-pr.yml") + | select(.head_sha == $head) + | .id // empty + ')" + expected_name="CodeQL compatibility analysis (${REQUIRED_LANGUAGE})" + job="$(gh api "repos/${TARGET_REPOSITORY}/actions/jobs/${REQUIRED_JOB_ID}")" + job_identity="$(printf '%s' "$job" | jq -r --arg head "$HEAD_SHA" --arg name "$expected_name" --argjson run_id "$REQUIRED_RUN_ID" --argjson job_id "$REQUIRED_JOB_ID" ' + select(.id == $job_id) + | select(.run_id == $run_id) + | select(.head_sha == $head) + | select(.name == $name) + | select(.status == "completed" and .conclusion == "failure") + | .id // empty + ')" + if [ "$run_identity" != "$REQUIRED_RUN_ID" ] || + [ "$job_identity" != "$REQUIRED_JOB_ID" ]; then + echo "::error::CodeQL wake rejected missing or ambiguous exact run/job identity." + exit 1 + fi + + gh api -X POST "repos/${TARGET_REPOSITORY}/actions/jobs/${REQUIRED_JOB_ID}/rerun" >/dev/null + echo "Re-ran exact failed CodeQL job ${REQUIRED_JOB_ID} for ${REQUIRED_LANGUAGE} on ${HEAD_SHA}." diff --git a/.github/workflows/current-head-run-coalescer.yml b/.github/workflows/current-head-run-coalescer.yml deleted file mode 100644 index a3c985a532..0000000000 --- a/.github/workflows/current-head-run-coalescer.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Current Head Run Coalescer - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] - -concurrency: - group: current-head-run-coalescer-${{ github.repository }}-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - actions: write - contents: read - pull-requests: read - -jobs: - coalesce: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout trusted control-plane source - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - repository: ContextualWisdomLab/.github - ref: ${{ github.workflow_sha }} - persist-credentials: false - - - name: Retire redundant queued exact-head runs - env: - GH_TOKEN: ${{ github.token }} - COALESCE_REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - EXPECTED_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - EXPECTED_HEAD_REF: ${{ github.event.pull_request.head.ref }} - EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} - shell: bash - run: | - set -euo pipefail - python3 scripts/ci/current_head_run_coalescer.py \ - --repo "$COALESCE_REPO" \ - --pr-number "$PR_NUMBER" \ - --expected-head-repo "$EXPECTED_HEAD_REPO" \ - --expected-head-ref "$EXPECTED_HEAD_REF" \ - --expected-head "$EXPECTED_HEAD" diff --git a/.github/workflows/exact-artifact-sbom-attestation-quality.yml b/.github/workflows/exact-artifact-sbom-attestation-quality.yml deleted file mode 100644 index 851878e2e3..0000000000 --- a/.github/workflows/exact-artifact-sbom-attestation-quality.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Exact Artifact SBOM Attestation Quality - -on: - pull_request: - branches: [main] - paths: - - ".github/workflows/exact-artifact-sbom-attestation.yml" - - ".github/workflows/exact-artifact-sbom-attestation-quality.yml" - - "scripts/ci/verify_exact_artifact_sbom_handoff.py" - - "tests/test_exact_artifact_sbom_attestation_contract.py" - - "tests/test_exact_artifact_sbom_review_regressions.py" - - "tests/test_verify_exact_artifact_sbom_handoff.py" - - "docs/doctoring/exact-artifact-sbom-attestation.md" - - "CHANGELOG.md" - push: - branches: [main] - paths: - - ".github/workflows/exact-artifact-sbom-attestation.yml" - - ".github/workflows/exact-artifact-sbom-attestation-quality.yml" - - "scripts/ci/verify_exact_artifact_sbom_handoff.py" - - "tests/test_exact_artifact_sbom_attestation_contract.py" - - "tests/test_exact_artifact_sbom_review_regressions.py" - - "tests/test_verify_exact_artifact_sbom_handoff.py" - - "docs/doctoring/exact-artifact-sbom-attestation.md" - - "CHANGELOG.md" - -concurrency: - group: exact-artifact-sbom-attestation-quality-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - minimum-python-contract: - name: Python 3.10 contract - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact contributor head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - - name: Verify exact workflow source checkout - env: - EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" - - - name: Set up minimum supported Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.10" - - - name: Compile production and contracts on Python 3.10 - run: | - python -m compileall -q \ - scripts/ci/verify_exact_artifact_sbom_handoff.py \ - tests/test_exact_artifact_sbom_attestation_contract.py \ - tests/test_exact_artifact_sbom_review_regressions.py \ - tests/test_verify_exact_artifact_sbom_handoff.py - - exact-contract: - name: Python 3.14 exact contract and complete coverage - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact contributor head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - - name: Verify exact workflow source checkout - env: - EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE_SHA" - - - name: Set up current stable Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install hash-locked quality tooling - run: python -m pip install --disable-pip-version-check --require-hashes -r requirements-opencode-review-ci-hashes.txt - - - name: Run exact contracts with complete verifier branch coverage - run: | - python -m coverage erase - python -m coverage run --branch -m pytest -q \ - tests/test_exact_artifact_sbom_attestation_contract.py \ - tests/test_exact_artifact_sbom_review_regressions.py \ - tests/test_verify_exact_artifact_sbom_handoff.py - python -m coverage report \ - --include=scripts/ci/verify_exact_artifact_sbom_handoff.py \ - --show-missing \ - --fail-under=100 - python -m interrogate --fail-under=100 scripts/ci/verify_exact_artifact_sbom_handoff.py - - - name: Compile production and contract files - run: | - python -m compileall -q \ - scripts/ci/verify_exact_artifact_sbom_handoff.py \ - tests/test_exact_artifact_sbom_attestation_contract.py \ - tests/test_exact_artifact_sbom_review_regressions.py \ - tests/test_verify_exact_artifact_sbom_handoff.py diff --git a/.github/workflows/exact-artifact-sbom-attestation.yml b/.github/workflows/exact-artifact-sbom-attestation.yml index f7f04a40b1..b038c5478e 100644 --- a/.github/workflows/exact-artifact-sbom-attestation.yml +++ b/.github/workflows/exact-artifact-sbom-attestation.yml @@ -163,6 +163,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: + actions: read contents: read id-token: write attestations: write @@ -189,6 +190,26 @@ jobs: sparse-checkout: scripts/ci/verify_exact_artifact_sbom_handoff.py sparse-checkout-cone-mode: false + - name: Verify immutable same-run artifact metadata + env: + GH_TOKEN: ${{ github.token }} + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + ARTIFACT_ID: ${{ inputs.evidence_artifact_id }} + ARTIFACT_NAME: ${{ inputs.evidence_artifact_name }} + ARTIFACT_DIGEST: ${{ inputs.evidence_artifact_digest }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$SOURCE_REPOSITORY" = "$GITHUB_REPOSITORY" + test "$SOURCE_SHA" = "$GITHUB_SHA" + artifact_json="$(gh api "/repos/${SOURCE_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}")" + jq -e \ + --arg name "$ARTIFACT_NAME" \ + --arg digest "$ARTIFACT_DIGEST" \ + --argjson run_id "$GITHUB_RUN_ID" \ + '.name == $name and .digest == $digest and .workflow_run.id == $run_id and .expired == false' \ + <<<"$artifact_json" >/dev/null + - name: Download exact sealed evidence without executing it uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -379,4 +400,4 @@ jobs: name: exact-artifact-sbom-offline-verification path: offline-attestation-evidence if-no-files-found: error - retention-days: 90 + retention-days: 90 \ No newline at end of file diff --git a/.github/workflows/exact-head-coverage-quality-gate.yml b/.github/workflows/exact-head-coverage-quality-gate.yml new file mode 100644 index 0000000000..6b957571d2 --- /dev/null +++ b/.github/workflows/exact-head-coverage-quality-gate.yml @@ -0,0 +1,91 @@ +name: Exact-Head Coverage Quality Gate + +# Reusable workflow_call gate shared by quality-CI callers that measure one +# scripts/ci module at 100% branch coverage against the exact PR head SHA. +# Caller: javascript-coverage-quality-ci.yml. +# +# Not every quality-CI workflow under .github/workflows/ fits this shape — +# harden-runner presence, docstring gates, exact-head verification mechanics, +# and multi-python-version matrices differ enough across the others +# (agent-mention-router, exact-artifact-sbom-attestation, noema-token-lifetime, +# opencode-rust-coverage-toolchain, strix-changed-path, trusted-uv-materializer) +# that forcing them into this same template would either weaken what they +# enforce or need enough per-caller toggles to defeat the point of sharing. + +on: + workflow_call: + inputs: + timeout_minutes: + description: Job timeout in minutes + required: true + type: number + pytest_target: + description: pytest path or glob to run under coverage + required: true + type: string + coverage_include: + description: Single scripts/ci module path passed to `coverage report --include` + required: true + type: string + compileall_targets: + description: Space-separated file list passed to `python -m compileall -q` + required: true + type: string + +permissions: + contents: read + +jobs: + quality-gate: + runs-on: ubuntu-24.04 + timeout-minutes: ${{ inputs.timeout_minutes }} + steps: + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.14' + - name: Install exact hash-verified quality dependencies + env: + PIP_DISABLE_PIP_VERSION_CHECK: '1' + PIP_NO_INPUT: '1' + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + cat >"${RUNNER_TEMP}/exact-head-coverage-quality-gate-requirements.txt" <<'EOF' + coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f + iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 + packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e + pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 + pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + EOF + python -m pip install \ + --only-binary=:all: \ + --require-hashes \ + -r "${RUNNER_TEMP}/exact-head-coverage-quality-gate-requirements.txt" + - name: Verify exact-head policy and full branch coverage + env: + PYTEST_TARGET: ${{ inputs.pytest_target }} + COVERAGE_INCLUDE: ${{ inputs.coverage_include }} + COMPILEALL_TARGETS: ${{ inputs.compileall_targets }} + shell: bash --noprofile --norc -e -o pipefail {0} + run: | + test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + # PYTEST_TARGET/COMPILEALL_TARGETS are deliberately unquoted: callers + # pass space-separated lists and glob patterns that must still word- + # split and expand. Routing workflow_call inputs through env instead + # of interpolating them directly into the script keeps a caller- + # controlled value from ever being re-parsed as shell syntax. + # shellcheck disable=SC2086 + python -m coverage run --branch -m pytest --import-mode=importlib $PYTEST_TARGET -q + python -m coverage report \ + --include="$COVERAGE_INCLUDE" \ + --show-missing \ + --fail-under=100 + # shellcheck disable=SC2086 + python -m compileall -q $COMPILEALL_TARGETS + git diff --exit-code diff --git a/.github/workflows/hourly-nvidia-nim-review-repair.yml b/.github/workflows/hourly-nvidia-nim-review-repair.yml deleted file mode 100644 index d87a5f3987..0000000000 --- a/.github/workflows/hourly-nvidia-nim-review-repair.yml +++ /dev/null @@ -1,195 +0,0 @@ -name: Contextual Orchestrator Review Repair Quality CI - -# Compatibility boundary: keep this historical file path so the existing GitHub -# Actions workflow registry identity is updated in place instead of leaving an -# orphaned enabled workflow ID. The display name and executable responsibility -# are authoritative: this is a read-only PR/push quality gate, not an hourly -# writer and not a direct NVIDIA NIM executor. -# -# Hourly execution is owned by the thin product callers and the reusable -# scheduler; write-capable repair is owned by pr-review-autofix.yml, whose model -# execution is routed through contextual-orchestrator/orchestrator/free. -on: - pull_request: - types: [opened, synchronize, reopened, closed] - paths: - - .github/workflows/pr-review-fix-scheduler.yml - - scripts/ci/pr_review_fix_scheduler.py - - .github/workflows/pr-review-autofix.yml - - .github/workflows/hourly-review-repair.yml - - .github/workflows/hourly-nvidia-nim-review-repair.yml - - scripts/ci/pr_review_conflict_scope.py - - scripts/ci/pr_review_autofix_context.py - - scripts/ci/zdr_policy.py - - scripts/ci/contextual_orchestrator_review_policy.py - - scripts/ci/contextual_orchestrator_review_launcher.py - - scripts/ci/contextual_orchestrator_review_sidecar.sh - - tests/test_zdr_policy.py - - tests/test_contextual_orchestrator_review_policy.py - - tests/test_contextual_orchestrator_review_sidecar_contract.py - - docs/doctoring/contextual-orchestrator-vendored-sidecar.md - - docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md - - docs/doctoring/review-repair-quality-workflow-identity.md - - docs/doctoring/hourly-review-repair-registry-retirement.md - - docs/product-technical-gap-baseline.md - - CHANGELOG.md - - tests/test_hourly_review_repair_callers.py - - tests/test_github_hourly_conflict_repair.py - - tests/test_hourly_scheduler_runtime_budget.py - - tests/test_hourly_autofix_context_quality_gate.py - - tests/test_pr_review_conflict_scope.py - - tests/test_pr_review_conflict_scope_control_files.py - - tests/test_pr_review_conflict_scope_git_executable.py - - tests/test_pr_review_conflict_scope_ignored_paths.py - - tests/test_pr_review_conflict_scope_symlink_targets.py - - tests/test_pr_review_fix_hourly_contract.py - - tests/test_pr_review_fix_scheduler.py - - tests/test_pr_review_fix_scheduler_source_pin.py - - tests/test_pr_review_autofix_context_head_binding.py - - tests/test_pr_review_autofix_nvidia_nim_contract.py - - tests/test_pr_review_autofix_writer_security_contract.py - - docs/automation/hourly-review-repair.md - - docs/doctoring/bandscope-hourly-review-caller.md - - docs/doctoring/clearfolio-hourly-review-caller.md - - docs/doctoring/conflict-control-evidence-isolation.md - - docs/doctoring/disksage-hourly-review-caller.md - - docs/doctoring/inkspan-hourly-review-caller.md - - docs/doctoring/lineageweave-hourly-review-caller.md - - docs/doctoring/fast-mlsirm-hourly-review-caller.md - - docs/doctoring/github-hourly-conflict-repair.md - - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - - docs/doctoring/hourly-nvidia-nim-autofix.md - - docs/doctoring/nonnest2-hourly-review-caller.md - - docs/doctoring/orgmetra-hourly-review-caller.md - - docs/doctoring/originweave-hourly-review-caller.md - - docs/doctoring/quarantine-sandbox-hourly-review-caller.md - - docs/doctoring/contextual-orchestrator-hourly-review-caller.md - - docs/doctoring/afipc-hourly-review-caller.md - push: - paths: - - .github/workflows/pr-review-fix-scheduler.yml - - scripts/ci/pr_review_fix_scheduler.py - - .github/workflows/pr-review-autofix.yml - - .github/workflows/hourly-review-repair.yml - - .github/workflows/hourly-nvidia-nim-review-repair.yml - - scripts/ci/pr_review_conflict_scope.py - - scripts/ci/pr_review_autofix_context.py - - scripts/ci/zdr_policy.py - - scripts/ci/contextual_orchestrator_review_policy.py - - scripts/ci/contextual_orchestrator_review_launcher.py - - scripts/ci/contextual_orchestrator_review_sidecar.sh - - tests/test_zdr_policy.py - - tests/test_contextual_orchestrator_review_policy.py - - tests/test_contextual_orchestrator_review_sidecar_contract.py - - docs/doctoring/contextual-orchestrator-vendored-sidecar.md - - docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md - - docs/doctoring/review-repair-quality-workflow-identity.md - - docs/doctoring/hourly-review-repair-registry-retirement.md - - docs/product-technical-gap-baseline.md - - CHANGELOG.md - - tests/test_hourly_review_repair_callers.py - - tests/test_github_hourly_conflict_repair.py - - tests/test_hourly_scheduler_runtime_budget.py - - tests/test_hourly_autofix_context_quality_gate.py - - tests/test_pr_review_conflict_scope.py - - tests/test_pr_review_conflict_scope_control_files.py - - tests/test_pr_review_conflict_scope_git_executable.py - - tests/test_pr_review_conflict_scope_ignored_paths.py - - tests/test_pr_review_conflict_scope_symlink_targets.py - - tests/test_pr_review_fix_hourly_contract.py - - tests/test_pr_review_fix_scheduler.py - - tests/test_pr_review_fix_scheduler_source_pin.py - - tests/test_pr_review_autofix_context_head_binding.py - - tests/test_pr_review_autofix_nvidia_nim_contract.py - - tests/test_pr_review_autofix_writer_security_contract.py - - docs/automation/hourly-review-repair.md - - docs/doctoring/bandscope-hourly-review-caller.md - - docs/doctoring/clearfolio-hourly-review-caller.md - - docs/doctoring/conflict-control-evidence-isolation.md - - docs/doctoring/disksage-hourly-review-caller.md - - docs/doctoring/inkspan-hourly-review-caller.md - - docs/doctoring/lineageweave-hourly-review-caller.md - - docs/doctoring/fast-mlsirm-hourly-review-caller.md - - docs/doctoring/github-hourly-conflict-repair.md - - docs/doctoring/governance-risk-compliance-hourly-review-caller.md - - docs/doctoring/hourly-nvidia-nim-autofix.md - - docs/doctoring/nonnest2-hourly-review-caller.md - - docs/doctoring/orgmetra-hourly-review-caller.md - - docs/doctoring/originweave-hourly-review-caller.md - - docs/doctoring/quarantine-sandbox-hourly-review-caller.md - - docs/doctoring/contextual-orchestrator-hourly-review-caller.md - - docs/doctoring/afipc-hourly-review-caller.md - -permissions: - contents: read - -concurrency: - group: contextual-orchestrator-review-repair-quality-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - contract: - if: ${{ github.event_name != 'pull_request' || github.event.action != 'closed' }} - name: Scheduler, contextual-orchestrator, writer, and conflict-scope contracts - 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: Checkout exact source revision - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - 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 - -r requirements-opencode-review-ci-hashes.txt - - name: Verify scheduler and contextual-orchestrator review-repair contracts - run: | - set -euo pipefail - python -m pytest -q \ - --cov=scripts.ci.pr_review_conflict_scope \ - --cov=scripts.ci.pr_review_autofix_context \ - --cov=scripts.ci.zdr_policy \ - --cov=scripts.ci.contextual_orchestrator_review_policy \ - --cov-branch \ - --cov-fail-under=100 - python -m interrogate \ - --fail-under 100 \ - scripts/ci/pr_review_conflict_scope.py \ - scripts/ci/pr_review_autofix_context.py \ - scripts/ci/zdr_policy.py \ - scripts/ci/contextual_orchestrator_review_policy.py \ - scripts/ci/contextual_orchestrator_review_launcher.py - python -m compileall -q \ - scripts/ci/pr_review_conflict_scope.py \ - scripts/ci/pr_review_autofix_context.py \ - tests/test_pr_review_conflict_scope.py \ - scripts/ci/zdr_policy.py \ - scripts/ci/contextual_orchestrator_review_policy.py \ - scripts/ci/contextual_orchestrator_review_launcher.py \ - tests/test_zdr_policy.py \ - tests/test_contextual_orchestrator_review_policy.py \ - tests/test_contextual_orchestrator_review_sidecar_contract.py \ - tests/test_hourly_review_repair_callers.py \ - tests/test_github_hourly_conflict_repair.py \ - tests/test_hourly_scheduler_runtime_budget.py \ - tests/test_pr_review_conflict_scope_control_files.py \ - tests/test_hourly_autofix_context_quality_gate.py \ - tests/test_pr_review_conflict_scope_git_executable.py \ - tests/test_pr_review_conflict_scope_ignored_paths.py \ - tests/test_pr_review_conflict_scope_symlink_targets.py \ - tests/test_pr_review_fix_hourly_contract.py \ - tests/test_pr_review_fix_scheduler.py \ - tests/test_pr_review_fix_scheduler_source_pin.py \ - tests/test_pr_review_autofix_context_head_binding.py \ - tests/test_pr_review_autofix_nvidia_nim_contract.py \ - tests/test_pr_review_autofix_writer_security_contract.py - git diff --check diff --git a/.github/workflows/hourly-review-repair.yml b/.github/workflows/hourly-review-repair.yml index 6ff60e4685..0b45c7fd37 100644 --- a/.github/workflows/hourly-review-repair.yml +++ b/.github/workflows/hourly-review-repair.yml @@ -1,4 +1,4 @@ -name: Hourly Review Repair +name: Daily Review Recovery # Consolidates the 18 former thin per-repository callers # (`-hourly-review-repair.yml`) into one file. GitHub Actions' own @@ -19,9 +19,10 @@ name: Hourly Review Repair # pr-review-fix-scheduler.yml"). Only the trigger/dispatch layer above it is # consolidated here. # -# Each `on.schedule` entry below keeps its original file's distinct -# minute-of-hour offset and staggering-rationale comment verbatim, so -# cadence is byte-for-byte unchanged. `resolve-target` reads +# Native PR and review events own normal progress. Each `on.schedule` entry +# below is only a daily missed-event recovery, distributed across UTC hours so +# this control plane admits at most one recovery workflow per hour instead of +# seventeen every hour. `resolve-target` reads # `github.event.schedule` -- the exact cron expression GitHub sets on the # triggering event -- to look up which repository(ies) that minute serves. # `dispatch-review-repair` then fans out over that lookup with a matrix, so @@ -39,13 +40,13 @@ on: # governance-risk-compliance (43), fast-mlsirm (49), BandScope (53), # Inkspan (56), orgmetra (58), and semantic-data-portal (59). # -- aFIPC (formerly afipc-hourly-review-repair.yml) - - cron: "2 * * * *" + - cron: "2 0 * * *" # -- LineageWeave (formerly lineageweave-hourly-review-repair.yml; the # original file stated no staggering rationale for this minute) - - cron: "4 * * * *" + - cron: "4 1 * * *" # Minute 9 avoids minute-zero pressure and the existing product callers. # -- psychometrics-commons (formerly psychometrics-commons-hourly-review-repair.yml) - - cron: "9 * * * *" + - cron: "9 2 * * *" # Minute 10 avoids pg-llm-batch (1), aFIPC (2), kaefa (3), LineageWeave (4), # codec-carver (5), life-os (6), Wardnet (7), mightyETL (8), # psychometrics-commons (9), naruon (11), pg-erd-cloud (13), @@ -54,11 +55,11 @@ on: # fast-mlsirm (49), BandScope (53), Inkspan (56), and # semantic-data-portal (59). # -- OriginWeave (formerly originweave-hourly-review-repair.yml) - - cron: "10 * * * *" + - cron: "10 3 * * *" # Minute 14 avoids existing product callers while keeping one bounded # review-repair heartbeat per hour for the sandbox runtime. # -- quarantine-sandbox (formerly quarantine-sandbox-hourly-review-repair.yml) - - cron: "14 * * * *" + - cron: "14 4 * * *" # Minute 16 avoids pg-llm-batch (1), aFIPC (2), kaefa (3), LineageWeave (4), # codec-carver (5), life-os (6), Wardnet (7), mightyETL (8), # psychometrics-commons (9), OriginWeave (10), naruon (11), @@ -68,27 +69,27 @@ on: # newsdom-api (43), fast-mlsirm (49), BandScope (53), Inkspan (56), # and semantic-data-portal (59). # -- nonnest2 (formerly nonnest2-hourly-review-repair.yml) - - cron: "16 * * * *" + - cron: "16 5 * * *" # Keep the control-plane queue moving without colliding with minute-zero jobs. # -- ContextualWisdomLab/.github self-caller (formerly github-hourly-review-repair.yml) - - cron: "21 * * * *" + - cron: "21 6 * * *" # Offset the heartbeat from minute zero to reduce shared-runner congestion. # -- Clearfolio (formerly clearfolio-hourly-review-repair.yml) - - cron: "23 * * * *" + - cron: "23 7 * * *" # Minute 27 avoids existing organization product callers and minute-zero pressure. # -- accounting-information-platform (formerly accounting-information-platform-hourly-review-repair.yml) - - cron: "27 * * * *" + - cron: "27 8 * * *" # Minute 34 avoids the minute-zero runner surge and every existing sibling # heartbeat (2, 7, 10, 14, 16, 17 central scheduler, 21, 23, 27, 31, # 37, 41, 43, 49, 53, 58, 59). # -- contextual-orchestrator (formerly contextual-orchestrator-hourly-review-repair.yml) - - cron: "34 * * * *" + - cron: "34 9 * * *" # Minute 37 avoids the minute-zero runner surge and the Clearfolio heartbeat. # -- DiskSage (formerly disksage-hourly-review-repair.yml) - - cron: "37 * * * *" + - cron: "37 10 * * *" # Minute 43 avoids minute-zero pressure and the existing product callers. # -- governance-risk-compliance (formerly governance-risk-compliance-hourly-review-repair.yml) - - cron: "43 * * * *" + - cron: "43 11 * * *" # Minute 49 avoids minute-zero pressure and the existing product callers. # Serves TWO repositories, fast-mlsirm and metering-billing-platform: their # original standalone files had both independently chosen minute 49, an @@ -101,24 +102,33 @@ on: # -- fast-mlsirm + metering-billing-platform (formerly # fast-mlsirm-hourly-review-repair.yml and # metering-billing-platform-hourly-review-repair.yml) - - cron: "49 * * * *" + - cron: "49 12 * * *" # Minute 53 avoids established product-specific heartbeat minutes. # -- BandScope (formerly bandscope-hourly-review-repair.yml) - - cron: "53 * * * *" + - cron: "53 13 * * *" # Minute 56 avoids every existing hourly heartbeat minute and the # half-hourly merge scheduler ticks. # -- Inkspan (formerly inkspan-hourly-review-repair.yml) - - cron: "56 * * * *" + - cron: "56 14 * * *" # Minute 58 avoids the existing product callers and leaves room for the # central merge scheduler to consume the queue. # -- Orgmetra (formerly orgmetra-hourly-review-repair.yml) - - cron: "58 * * * *" + - cron: "58 15 * * *" # Minute 59 is reserved for semantic-data-portal in the organization # caller ledger and is unique among product heartbeats. GitHub may delay # scheduled runs, so this is a heartbeat rather than a minute-zero surge # avoidance guarantee. # -- semantic-data-portal (formerly semantic-data-portal-hourly-review-repair.yml) - - cron: "59 * * * *" + - cron: "59 16 * * *" + +# Coalesce admissions before resolve-target needs a runner. +# GitHub keeps at most one running and one pending workflow per group by default. +# A newer +# pending heartbeat replaces the older pending heartbeat; cancel-in-progress +# stays false, so it does not cancel the running repository scan. +concurrency: + group: hourly-review-repair-${{ github.event.schedule }} + cancel-in-progress: false permissions: contents: read @@ -126,7 +136,7 @@ permissions: jobs: resolve-target: name: Resolve target(s) for ${{ github.event.schedule }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: targets: ${{ steps.lookup.outputs.targets }} steps: @@ -137,72 +147,72 @@ jobs: run: | set -euo pipefail case "$SCHEDULE" in - "2 * * * *") + "2 0 * * *") # A later heartbeat must not cancel an in-flight FIPC or calibration RCA. TARGETS='[{"name":"afipc","target_repository":"ContextualWisdomLab/aFIPC","base_branch":"master","retry_hours":"2","concurrency_group":"afipc-hourly-review-repair"}]' ;; - "4 * * * *") + "4 1 * * *") TARGETS='[{"name":"lineageweave","target_repository":"ContextualWisdomLab/LineageWeave","base_branch":"*","retry_hours":"2","concurrency_group":"lineageweave-hourly-review-repair"}]' ;; - "9 * * * *") + "9 2 * * *") # Preserve bounded RCA when a later hourly heartbeat arrives. TARGETS='[{"name":"psychometrics-commons","target_repository":"ContextualWisdomLab/psychometrics-commons","base_branch":"main","retry_hours":"2","concurrency_group":"psychometrics-commons-hourly-review-repair"}]' ;; - "10 * * * *") + "10 3 * * *") # A later heartbeat must not cancel an in-flight agent-browser RCA. TARGETS='[{"name":"originweave","target_repository":"ContextualWisdomLab/OriginWeave","base_branch":"main","retry_hours":"2","concurrency_group":"originweave-hourly-review-repair"}]' ;; - "14 * * * *") + "14 4 * * *") # A later heartbeat must not cancel an in-flight security RCA. TARGETS='[{"name":"quarantine-sandbox","target_repository":"ContextualWisdomLab/quarantine-sandbox-runtime","base_branch":"develop","retry_hours":"2","concurrency_group":"quarantine-sandbox-hourly-review-repair"}]' ;; - "16 * * * *") + "16 5 * * *") # A later heartbeat must not cancel an in-flight Vuong or fit RCA. TARGETS='[{"name":"nonnest2","target_repository":"ContextualWisdomLab/nonnest2","base_branch":"master","retry_hours":"2","concurrency_group":"nonnest2-hourly-review-repair"}]' ;; - "21 * * * *") + "21 6 * * *") TARGETS='[{"name":"github","target_repository":"ContextualWisdomLab/.github","base_branch":"main","retry_hours":"1","concurrency_group":"github-hourly-review-repair"}]' ;; - "23 * * * *") + "23 7 * * *") TARGETS='[{"name":"clearfolio","target_repository":"ContextualWisdomLab/clearfolio","base_branch":"main","retry_hours":"1","concurrency_group":"clearfolio-hourly-review-repair"}]' ;; - "27 * * * *") + "27 8 * * *") # Central OpenCode, Noema, and exact-head accounting checks can exceed one hour. TARGETS='[{"name":"accounting-information-platform","target_repository":"ContextualWisdomLab/accounting-information-platform","base_branch":"develop","retry_hours":"2","concurrency_group":"accounting-information-platform-hourly-review-repair"}]' ;; - "34 * * * *") + "34 9 * * *") # The queue scan is bounded and the worker has its own exact-head lease. Do not # discard an in-flight RCA merely because the next hourly heartbeat arrives. TARGETS='[{"name":"contextual-orchestrator","target_repository":"ContextualWisdomLab/contextual-orchestrator","base_branch":"main","retry_hours":"2","concurrency_group":"contextual-orchestrator-hourly-review-repair"}]' ;; - "37 * * * *") + "37 10 * * *") # The queue scan is bounded and the worker has its own exact-head lease. Do not # discard an in-flight RCA merely because the next hourly heartbeat arrives. TARGETS='[{"name":"disksage","target_repository":"ContextualWisdomLab/disksage","base_branch":"main","retry_hours":"2","concurrency_group":"disksage-hourly-review-repair"}]' ;; - "43 * * * *") + "43 11 * * *") # Preserve an in-flight exact-head RCA when the next heartbeat arrives. TARGETS='[{"name":"governance-risk-compliance","target_repository":"ContextualWisdomLab/governance-risk-compliance","base_branch":"develop","retry_hours":"2","concurrency_group":"governance-risk-compliance-hourly-review-repair"}]' ;; - "49 * * * *") + "49 12 * * *") # fast-mlsirm: preserve bounded RCA when a later hourly heartbeat arrives. # metering-billing-platform: preserve bounded RCA when a later hourly heartbeat arrives. TARGETS='[{"name":"fast-mlsirm","target_repository":"ContextualWisdomLab/fast-mlsirm","base_branch":"main","retry_hours":"2","concurrency_group":"fast-mlsirm-hourly-review-repair"},{"name":"metering-billing-platform","target_repository":"ContextualWisdomLab/metering-billing-platform","base_branch":"develop","retry_hours":"1","concurrency_group":"metering-billing-platform-hourly-review-repair"}]' ;; - "53 * * * *") + "53 13 * * *") # Preserve a legitimate long-running root-cause analysis across heartbeats. TARGETS='[{"name":"bandscope","target_repository":"ContextualWisdomLab/bandscope","base_branch":"develop","retry_hours":"2","concurrency_group":"bandscope-hourly-review-repair"}]' ;; - "56 * * * *") + "56 14 * * *") # The queue scan is bounded and the worker has its own exact-head lease. Do not # discard an in-flight RCA merely because the next hourly heartbeat arrives. TARGETS='[{"name":"inkspan","target_repository":"ContextualWisdomLab/inkspan","base_branch":"main","retry_hours":"2","concurrency_group":"inkspan-hourly-review-repair"}]' ;; - "58 * * * *") + "58 15 * * *") # Preserve an in-flight exact-head RCA when the next heartbeat arrives. TARGETS='[{"name":"orgmetra","target_repository":"ContextualWisdomLab/Orgmetra","base_branch":"develop","retry_hours":"2","concurrency_group":"orgmetra-hourly-review-repair"}]' ;; - "59 * * * *") + "59 16 * * *") # The queue scan is bounded and the worker has its own exact-head lease. Do not # discard an in-flight RCA merely because the next hourly heartbeat arrives. TARGETS='[{"name":"semantic-data-portal","target_repository":"ContextualWisdomLab/semantic-data-portal","base_branch":"main","retry_hours":"2","concurrency_group":"semantic-data-portal-hourly-review-repair"}]' @@ -237,8 +247,18 @@ jobs: with: target_repository: ${{ matrix.target_repository }} base_branch: ${{ matrix.base_branch }} - max_prs: "50" + # The reusable scheduler's own default (also "50") is too low for a + # queue this size: this repository alone (one of the 20 targets below) + # had 117 open PRs as of 2026-09-03, and BandScope independently hit + # 136 (see the now-superseded #1397, whose fix predates this file and + # never reached main before its target file was consolidated away). + # An oldest-first scan capped at 50 never reaches a repository's newer + # non-draft work once its queue exceeds that bound. 200 mirrors #1397's + # own chosen bound. + max_prs: "200" max_dispatches: "1" + scan_window_size: "50" + rotation_seed: ${{ format('{0}', github.run_number) }} retry_hours: ${{ matrix.retry_hours }} # Explicit for every target: the reusable workflow's own default is # already `true`, so this is behaviorally identical to the 17 original diff --git a/.github/workflows/javascript-coverage-quality-ci.yml b/.github/workflows/javascript-coverage-quality-ci.yml index d9c8c74299..97ca851538 100644 --- a/.github/workflows/javascript-coverage-quality-ci.yml +++ b/.github/workflows/javascript-coverage-quality-ci.yml @@ -5,59 +5,26 @@ on: branches: [main] paths: - '.github/workflows/javascript-coverage-quality-ci.yml' + - '.github/workflows/exact-head-coverage-quality-gate.yml' - 'scripts/ci/javascript_coverage_gate.py' - 'tests/test_javascript_coverage_gate.py' - 'tests/test_javascript_coverage_storybook_boundary.py' + - 'tests/test_exact_head_coverage_quality_gate_contract.py' permissions: contents: read concurrency: - group: javascript-coverage-quality-${{ github.event.pull_request.number || github.ref }} + group: javascript-coverage-quality-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: exact-head-coverage-contract: - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Checkout exact source revision - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: '3.14' - - name: Install exact hash-verified quality dependencies - env: - PIP_DISABLE_PIP_VERSION_CHECK: '1' - PIP_NO_INPUT: '1' - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - cat >"${RUNNER_TEMP}/javascript-coverage-quality-requirements.txt" <<'EOF' - coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f - iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 - packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e - pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c - EOF - python -m pip install \ - --only-binary=:all: \ - --require-hashes \ - -r "${RUNNER_TEMP}/javascript-coverage-quality-requirements.txt" - - name: Verify full central suite and classifier coverage - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" - python -m coverage run --branch -m pytest --import-mode=importlib tests -q - python -m coverage report \ - --include='scripts/ci/javascript_coverage_gate.py' \ - --show-missing \ - --fail-under=100 - python -m compileall -q \ - scripts/ci/javascript_coverage_gate.py \ - tests/test_javascript_coverage_gate.py \ - tests/test_javascript_coverage_storybook_boundary.py - git diff --exit-code + uses: ./.github/workflows/exact-head-coverage-quality-gate.yml + with: + timeout_minutes: 15 + pytest_target: tests + coverage_include: scripts/ci/javascript_coverage_gate.py + compileall_targets: >- + scripts/ci/javascript_coverage_gate.py + tests/test_javascript_coverage_gate.py + tests/test_javascript_coverage_storybook_boundary.py diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index 30c9e9a517..f8ab55c896 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -9,22 +9,21 @@ run-name: >- on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, closed] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] # Default-branch-only retry entrypoint; no caller-selected workflow ref. repository_dispatch: types: [noema-review] concurrency: + # Workflow-level admission is required: a queued run cannot reach a job-level + # cancellation guard while the organization is at its Actions job ceiling. group: >- - noema-review-${{ + required-noema-review-${{ github.event.pull_request.base.repo.full_name || github.event.client_payload.target_repository || github.repository }}-${{ github.event.pull_request.number || - github.event.client_payload.pr_number || - github.run_id }} - cancel-in-progress: >- - ${{ github.event_name == 'pull_request_target' && - (github.event.action == 'synchronize' || github.event.action == 'closed') }} + github.event.client_payload.pr_number || github.run_id }} + cancel-in-progress: true permissions: contents: read @@ -33,8 +32,53 @@ permissions: id-token: write jobs: + admit-current-head: + if: >- + github.event_name == 'repository_dispatch' + || ( + github.event_name == 'pull_request_target' + && github.event.action != 'closed' + && github.event.action != 'converted_to_draft' + && github.event.pull_request.head.repo.full_name == github.repository + ) + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + admitted: ${{ steps.live_head.outputs.admitted }} + permissions: + contents: read + pull-requests: read + env: + GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.client_payload.target_repository || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.client_payload.pr_number || '' }} + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.client_payload.pr_head_sha || '' }} + steps: + - name: Admit only the exact live Noema head + id: live_head + run: | + set -euo pipefail + echo "admitted=false" >>"$GITHUB_OUTPUT" + if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Noema admission rejected malformed pull request metadata." + exit 1 + fi + live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_head="$(jq -r '.head.sha // empty' <<<"$live_pr")" + live_state="$(jq -r '.state // empty' <<<"$live_pr")" + if [ "${live_head,,}" != "${EXPECTED_HEAD_SHA,,}" ] || [ "$live_state" != "open" ]; then + echo "::notice::Noema admission retired a stale trigger before review queue entry." + exit 0 + fi + echo "admitted=true" >>"$GITHUB_OUTPUT" + echo "Exact live Noema head admitted for ${TARGET_REPOSITORY}#${PR_NUMBER}." + cancel-closed-pr-runs: - if: github.event_name == 'pull_request_target' && github.event.action == 'closed' + if: >- + github.event_name == 'pull_request_target' && + (github.event.action == 'closed' || github.event.action == 'converted_to_draft') runs-on: ubuntu-24.04 # Bound this job well short of GitHub's 360-minute platform default. Its # only step is a single-repository, status-filtered gh api --paginate @@ -49,14 +93,31 @@ jobs: env: GH_TOKEN: ${{ github.token }} TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} - CLOSED_PR_NUMBER: ${{ github.event.pull_request.number }} + INACTIVE_PR_NUMBER: ${{ github.event.pull_request.number }} + INACTIVE_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 Noema reviews for the closed pull request + - name: Cancel queued and running Noema reviews for the inactive pull request shell: bash run: | set -euo pipefail + live_target_matches() { + local live_pr_json live_state live_draft live_head + if ! live_pr_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${INACTIVE_PR_NUMBER}" 2>/tmp/noema-inactive-gh-error)"; then + echo "::warning::Noema inactive-PR cleanup could not verify the live pull request; leaving runs unchanged." >&2 + return 1 + fi + live_state="$(jq -r '.state // ""' <<<"$live_pr_json")" + live_draft="$(jq -r '.draft // false' <<<"$live_pr_json")" + live_head="$(jq -r '.head.sha // ""' <<<"$live_pr_json")" + [ "$live_head" = "$INACTIVE_PR_HEAD_SHA" ] && { + { [ "$PR_ACTION" = "closed" ] && [ "$live_state" = "closed" ]; } || + { [ "$PR_ACTION" = "converted_to_draft" ] && [ "$live_state" = "open" ] && [ "$live_draft" = "true" ]; } + } + } + # cancel_runs prints the number of runs it matched for $1's status # on stdout (its only stdout output) so the multi-pass loop below # can tell whether a pass found anything; all human-facing log @@ -81,6 +142,11 @@ jobs: # and latency risk here. cancel_runs() { local status="$1" + if ! live_target_matches; then + echo "::notice::Noema inactive-PR cleanup target changed; leaving runs unchanged." >&2 + echo 0 + 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/noema-close-gh-error)"; then @@ -122,7 +188,7 @@ jobs: # display_title, only carries the bare workflow name for a # required-workflow-ruleset run), `.path` was independently # confirmed stable across both native and sibling contexts. - if ! run_ids="$(jq -r --arg pr "$CLOSED_PR_NUMBER" \ + if ! run_ids="$(jq -r --arg pr "$INACTIVE_PR_NUMBER" \ --arg current "$CURRENT_RUN_ID" --arg target "$TARGET_REPOSITORY" ' .workflow_runs[] | select((.id | tostring) != $current) @@ -141,9 +207,13 @@ jobs: local matched=0 while IFS= read -r run_id; do [ -n "$run_id" ] || continue + if ! live_target_matches; then + echo "::notice::Noema inactive-PR cleanup target changed before cancellation; leaving runs unchanged." >&2 + break + fi matched=$((matched + 1)) if gh api --method POST "repos/${TARGET_REPOSITORY}/actions/runs/${run_id}/cancel" >/dev/null 2>/tmp/noema-close-cancel-error; then - echo "Cancelled Noema run ${run_id} in ${TARGET_REPOSITORY} for closed PR #${CLOSED_PR_NUMBER}." >&2 + echo "Cancelled Noema run ${run_id} in ${TARGET_REPOSITORY} for inactive PR #${INACTIVE_PR_NUMBER}." >&2 else echo "::warning::Noema close cleanup could not cancel run ${run_id}; it may have finished or the token lacks Actions write access." >&2 sed 's/^/ /' /tmp/noema-close-cancel-error >&2 || true @@ -186,6 +256,7 @@ jobs: noema-review: name: noema-review + needs: [admit-current-head] runs-on: ubuntu-24.04 # No job-level timeout-minutes here, deliberately. This job's "Prepare # Noema model verdict" step calls two_phase.py's call_llm synchronously @@ -204,11 +275,15 @@ jobs: # cap the policy forbids. See # docs/doctoring/autofix-and-noema-review-model-job-timeout-removal.md. if: >- - github.event_name == 'repository_dispatch' - || ( + needs.admit-current-head.outputs.admitted == 'true' + && ( + github.event_name == 'repository_dispatch' + || ( github.event_name == 'pull_request_target' && github.event.action != 'closed' + && github.event.action != 'converted_to_draft' && github.event.pull_request.head.repo.full_name == github.repository + ) ) permissions: actions: write @@ -613,6 +688,17 @@ jobs: echo "::notice::Noema model phase produced no publishable envelope; publication is skipped." fi + - name: Upload contextual-orchestrator sidecar evidence on failure + if: failure() && env.PR_NUMBER != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: noema-sidecar-evidence + path: | + strix_runs/contextual-orchestrator-sidecar.stderr.log + strix_runs/contextual-orchestrator-preflight.json + if-no-files-found: ignore + retention-days: 5 + - 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 diff --git a/.github/workflows/noema-token-lifetime-quality-ci.yml b/.github/workflows/noema-token-lifetime-quality-ci.yml deleted file mode 100644 index ef663df16f..0000000000 --- a/.github/workflows/noema-token-lifetime-quality-ci.yml +++ /dev/null @@ -1,53 +0,0 @@ -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 - - tests/test_noema_refreshed_app_identity.py - - tests/test_noema_token_lifetime_stale_run_contract.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 - -# Deterministic quality CI: a synchronize supersedes older work for this PR. -concurrency: - group: noema-token-lifetime-quality-${{ github.event.pull_request.base.repo.full_name }}-pr-${{ github.event.pull_request.number }} - cancel-in-progress: true - -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 \ - tests/test_noema_refreshed_app_identity.py \ - tests/test_noema_token_lifetime_stale_run_contract.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 \ - tests/test_noema_refreshed_app_identity.py \ - tests/test_noema_token_lifetime_stale_run_contract.py - git diff --check diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index bb5d439c3f..d86497b3f4 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -12,12 +12,25 @@ on: types: [opencode-review] concurrency: - # PR-number scope keeps stale dispatches replaced for the current head. + # Workflow-level admission, for the same reason strix.yml, noema-review.yml and + # opencode-review.yml carry theirs at this level: a job-level group is never + # evaluated while the whole run waits behind the organization job ceiling, so + # superseded dispatches for one pull request coalesce only after each of them + # has already been allocated a runner. Measured on 2026-09-06: of the five + # dispatch runs that passed `validate-pr-metadata`, four were rejected hours + # later by `opencode-review`'s privileged metadata check because the head had + # moved while they queued (runs 34002473295, 34010256951, 34015973300, + # 34016922761) -- each after `coverage-source-tree` and `coverage-evidence` + # had run. Cancelling the superseded run at creation returns that slot instead + # of spending it to discover the review's subject no longer exists. + # + # The key is the target pull request, matching the job-level group below and + # codeql-scan-dispatch.yml's workflow-level group; `github.run_id` keeps runs + # without a payload in their own groups rather than colliding. group: >- - opencode-review-repository-dispatch-${{ + opencode-review-dispatch-${{ github.event.client_payload.target_repository || github.repository }}-${{ - github.event.client_payload.pr_number && format('pr-{0}', github.event.client_payload.pr_number) || - github.run_id }} + github.event.client_payload.pr_number || github.run_id }} cancel-in-progress: true permissions: @@ -27,7 +40,7 @@ jobs: validate-pr-metadata: name: validate-pr-metadata if: github.event_name == 'repository_dispatch' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 8 permissions: contents: read @@ -133,10 +146,26 @@ jobs: run: | set -euo pipefail if [ "$EVENT_NAME" = "repository_dispatch" ]; then - if [ -z "$ALLOWED_DISPATCH_ACTOR" ] || - [ "$DISPATCH_ACTOR" != "$ALLOWED_DISPATCH_ACTOR" ] || - [ "$DISPATCH_SENDER" != "$ALLOWED_DISPATCH_ACTOR" ]; then - printf '::error::repository_dispatch authorization rejected actor=%s sender=%s because both must match the configured scheduler identity.\n' "${DISPATCH_ACTOR:-}" "${DISPATCH_SENDER:-}" + # More than one trusted identity dispatches this workflow: + # opencode-review.yml sends through the OpenCode GitHub App + # (opencode-agent[bot]) while pr-review-merge-scheduler.yml sends + # with its own token chain. Accept a comma-separated allowlist, + # parsed exactly like ALLOWED_DISPATCH_TARGETS below. The actor + # AND the sender must both equal the SAME allowlisted identity; + # an empty allowlist admits nothing. + actor_allowed=0 + IFS=',' read -r -a allowed_dispatch_actors <<<"$ALLOWED_DISPATCH_ACTOR" + for allowed_actor in "${allowed_dispatch_actors[@]}"; do + allowed_actor="${allowed_actor//[[:space:]]/}" + if [ -n "$allowed_actor" ] && + [ "$DISPATCH_ACTOR" = "$allowed_actor" ] && + [ "$DISPATCH_SENDER" = "$allowed_actor" ]; then + actor_allowed=1 + break + fi + done + if [ "$actor_allowed" -ne 1 ]; then + printf '::error::repository_dispatch authorization rejected actor=%s sender=%s because both must match one configured scheduler identity.\n' "${DISPATCH_ACTOR:-}" "${DISPATCH_SENDER:-}" exit 1 fi @@ -218,7 +247,7 @@ jobs: if: >- needs.validate-pr-metadata.result == 'success' && github.event_name == 'repository_dispatch' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 12 permissions: contents: read @@ -366,7 +395,7 @@ jobs: && needs.validate-pr-metadata.result == 'success' && needs.coverage-source-tree.result != 'cancelled' && github.event_name == 'repository_dispatch' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 300 permissions: # The PR tree arrives through a same-run artifact. No repository-content, @@ -2299,7 +2328,13 @@ jobs: && needs.validate-pr-metadata.result == 'success' && needs.coverage-evidence.result != 'cancelled' && github.event_name == 'repository_dispatch' - runs-on: ubuntu-latest + concurrency: + group: >- + opencode-review-${{ + needs.validate-pr-metadata.outputs.target_repository }}-${{ + needs.validate-pr-metadata.outputs.pr_number || github.run_id }} + cancel-in-progress: true + runs-on: ubuntu-24.04 # Coverage and current-head evidence are prepared before the model pool. # A single legitimate review may need a full hour. The enclosing job must # contain the 12-minute evidence step, 205-minute provider-pool step, the @@ -5601,6 +5636,24 @@ jobs: fi finding_index=$((finding_index + 1)) + + # The gate emits a second token for the class it can tell apart: + # STRIX_SANDBOX_UNAVAILABLE means Strix's own sandbox container + # never reached its Caido proxy, so the run died before the + # gateway served anything. Reporting that as "the gateway or its + # provider pool was unavailable" sends the reader to the wrong + # component -- the misattribution #1953 fixed in the gate itself, + # which survived here because this text was fixed for every + # STRIX_PROVIDER_UNAVAILABLE line. + if grep -q "STRIX_SANDBOX_UNAVAILABLE" "$strix_evidence_file"; then + printf '### %s. HIGH %s:%s - Strix sandbox bootstrap blocked current-head security evidence\n' "$finding_index" "$path" "$line" + printf -- '- Problem: Strix failed before producing vulnerability reports. The failed log reported STRIX_SANDBOX_UNAVAILABLE, which the gate emits when the run ended in Strix sandbox bootstrap after its bounded sandbox-specific retries.\n' + printf -- '- Root cause: Strix sandbox container did not reach its Caido proxy on 127.0.0.1, so the scan ended before any Vulnerability Report window was produced. This verdict names Strix sandbox, not the contextual-orchestrator gateway, and there is no application source line to patch from this evidence.\n' + printf -- '- Fix: Do not approve from this failed scan. Re-run Strix; the sandbox bootstrap is a startup race and the gate already retries it once. Do not change gateway or provider configuration on the strength of this finding.\n' + printf -- '- Regression test: Keep the gate emitting STRIX_SANDBOX_UNAVAILABLE for sandbox bootstrap failures and keep this consumer reading it, so a sandbox outage is never reported as a gateway outage.\n\n' + return 0 + fi + printf '### %s. HIGH %s:%s - Contextual-orchestrator provider availability blocked current-head security evidence\n' "$finding_index" "$path" "$line" printf -- '- Problem: Strix failed before producing vulnerability reports. The failed log reported LLM CONNECTION FAILED, RateLimitError or Too many requests, budget-limit output, gateway exhaustion, and Configured model and fallback models were unavailable.\n' printf -- '- Root cause: The contextual-orchestrator gateway or its discovered provider pool was unavailable for this run; no Strix Vulnerability Report window was produced, so there is no application source line to patch from this evidence.\n' diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index a7415cee22..19ea58003f 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -9,31 +9,20 @@ on: # content and never binds repository secrets. Privileged review execution is # isolated in opencode-review-dispatch.yml on repository_dispatch only. pull_request_target: - # `converted_to_draft` is included so a PR going draft mid-poll fires a - # fresh run of this same workflow: the head-scoped concurrency group below - # (`cancel-in-progress: true`) cancels any in-flight non-draft - # "Fail closed without a current-head OpenCode verdict" poll for that - # exact same head. Every non-closed admission path revalidates the live - # PR/head/state before dispatching, exempting, or polling so out-of-order - # draft/ready/closed events cannot publish stale evidence or wait on an - # impossible verdict. + # `converted_to_draft` is included so a draft conversion gets an immediate + # exempting run. Every non-closed + # admission path revalidates the live PR/head/state before dispatching, + # exempting, or checking the receipt so out-of-order draft/ready/closed + # events cannot publish stale evidence. types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] concurrency: - # Scoped by exact head SHA (not just PR number) so a delayed, out-of-order - # run for an older head cannot cancel the authoritative run already active - # for a newer head -- GitHub cancels whichever run is currently active in - # the group when a new one starts, with no notion of "older"/"newer", so - # sharing a group across different heads let a stale event retire the - # current head's still-valid run before its own live-head check could ever - # reject it (Devin Review on `#1568`). Same-head events (draft<->ready - # transitions, a synchronize retry) still share one group, so - # `converted_to_draft` still cancels an active same-head verdict poll. + # Coalesce before runner admission. The live-head job and scheduler still + # reject or replace a delayed stale event after native queue cancellation. group: >- - opencode-review-bootstrap-${{ + required-opencode-review-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ - github.event.pull_request.number || github.run_id }}-${{ - github.event.pull_request.head.sha || github.run_id }} + github.event.pull_request.number || github.run_id }} cancel-in-progress: true permissions: @@ -247,9 +236,50 @@ jobs: --event-action "$EVENT_ACTION" \ --api-url "https://api.github.com" + admit-current-head: + name: admit-current-head + needs: [required-workflow-bootstrap] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + admitted: ${{ steps.live_head.outputs.admitted }} + permissions: + contents: read + pull-requests: read + steps: + - name: Admit only the exact live OpenCode head + id: live_head + env: + GH_TOKEN: ${{ github.token }} + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} + EXPECTED_ACTION: ${{ github.event.action || '' }} + run: | + set -euo pipefail + echo "admitted=false" >>"$GITHUB_OUTPUT" + if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::OpenCode admission rejected malformed pull request metadata." + exit 1 + fi + live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_head="$(jq -r '.head.sha // empty' <<<"$live_pr")" + live_state="$(jq -r '.state // empty' <<<"$live_pr")" + expected_state=open + [ "$EXPECTED_ACTION" = "closed" ] && expected_state=closed + if [ "${live_head,,}" != "${EXPECTED_HEAD_SHA,,}" ] || [ "$live_state" != "$expected_state" ]; then + echo "::notice::OpenCode admission retired a stale event before review queue entry." + exit 0 + fi + echo "admitted=true" >>"$GITHUB_OUTPUT" + echo "Exact live OpenCode head admitted for ${TARGET_REPOSITORY}#${PR_NUMBER}." + coverage-source-tree: name: coverage-source-tree - needs: [required-workflow-bootstrap] + needs: [required-workflow-bootstrap, admit-current-head] + if: needs.admit-current-head.outputs.admitted == 'true' runs-on: ubuntu-24.04 steps: - run: >- @@ -258,7 +288,18 @@ jobs: coverage-evidence: name: coverage-evidence - needs: [coverage-source-tree] + # Deliberately NOT `needs: [coverage-source-tree]`. Neither job declares + # `outputs:`, so that edge only ordered two single-`echo` context holders -- + # and a job is not created until its `needs:` complete, so under a saturated + # queue each link waits out the whole queue again. Measured on + # naruon#1528 (run 33581213805): coverage-source-tree waited 9h40m to run for + # 4s, then coverage-evidence waited a further 13h01m to run for 5s, holding + # the actual review behind ~22h41m of pure queueing. Depending on + # `admit-current-head` directly lets the two run in parallel. The `if:` below + # restates the admission gate this job previously inherited transitively + # through coverage-source-tree, so an unadmitted head still skips it. + needs: [required-workflow-bootstrap, admit-current-head] + if: needs.admit-current-head.outputs.admitted == 'true' runs-on: ubuntu-24.04 steps: - run: >- @@ -267,7 +308,17 @@ jobs: opencode-review-target: name: opencode-review - needs: [coverage-evidence] + # `coverage-evidence` is deliberately absent here. This job never reads it + # at runtime -- the only consumer of that context is + # `opencode-review-dispatch.yml`, which resolves it through + # `scripts/ci/opencode_coverage_identity.py` against the check-runs API on + # its own schedule, so it does not care when this job ran relative to it. + # The edge was pure ordering, and ordering is expensive: a job is not + # created until its `needs:` finish, so this link cost a further 12h13m of + # queue wait on naruon#1528 (run 33581213805). Admission is still enforced + # directly by this job's own `if:` below, not inherited through that edge. + needs: [admit-current-head] + if: needs.admit-current-head.outputs.admitted == 'true' runs-on: ubuntu-24.04 permissions: contents: read @@ -285,6 +336,8 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_DRAFT: ${{ github.event.pull_request.draft }} BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail @@ -369,8 +422,12 @@ jobs: jq -cn \ --arg target_repository "$TARGET_REPOSITORY" \ --arg pr_number "$PR_NUMBER" \ - --arg base_branch "$BASE_BRANCH" \ - '{event_type:"merge-scheduler",client_payload:{target_repository:$target_repository,pr_number:$pr_number,base_branch:$base_branch,max_prs:"1",review_dispatch_limit:"1",trigger_reviews:true,enable_auto_merge:false,update_branches:false,dry_run:false}}' | + --arg pr_base_ref "$BASE_BRANCH" \ + --arg pr_base_sha "$BASE_SHA" \ + --arg pr_head_ref "$HEAD_REF" \ + --arg pr_head_sha "$HEAD_SHA" \ + --arg required_run_id "$GITHUB_RUN_ID" \ + '{event_type:"opencode-review",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head_ref:$pr_head_ref,pr_head_sha:$pr_head_sha,required_run_id:$required_run_id}}' | GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input - - name: Fail closed without a current-head OpenCode verdict @@ -412,82 +469,14 @@ jobs: exit 0 fi if [ "${live_head,,}" != "${HEAD_SHA,,}" ]; then - echo "Pull request head moved on the live open, ready-for-review PR; a fresh poll will start for the current head." + echo "Pull request head moved on the live open, ready-for-review PR; a fresh run will check the current head." exit 0 fi if [ "$PR_DRAFT" = "true" ]; then - echo "Event draft snapshot is stale; continuing verdict polling for the live ready PR." + echo "Event draft snapshot is stale; checking the verdict for the live ready PR." fi - verdict="" - live_poll_failures=0 - review_poll_failures=0 - max_poll_transport_failures=3 - poll_interval_seconds=60 - # Wall-clock backstop, distinct from max_poll_transport_failures above: - # that counter only bounds *consecutive transport failures*, so a - # review dispatch that never produces a verdict -- while every - # individual `gh api` call keeps succeeding -- previously polled - # forever, holding a live runner for up to GitHub's 360-minute - # platform default job timeout. 10800s (3h) is chosen to stay - # comfortably above this org's own documented "accommodate over 2 - # hours per model" allowance (docs/product-goal-directive.md §8) - # while still releasing the runner well before the platform - # default. This bounds how long the CI job waits for a verdict; it - # does not cap the model's own reasoning/streaming time, which - # remains governed entirely upstream by the dispatched review run - # itself. - poll_deadline_epoch=$(( $(date -u +%s) + 10800 )) - while :; do - if [ "$(date -u +%s)" -ge "$poll_deadline_epoch" ]; then - echo "::error::No current-head OpenCode verdict after 180 minutes of polling; failing closed and releasing the runner." - exit 1 - fi - if ! live_poll_pr="$(timeout 30s gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then - live_poll_failures=$((live_poll_failures + 1)) - if [ "$live_poll_failures" -ge "$max_poll_transport_failures" ]; then - echo "::error::Live pull request read failed ${live_poll_failures} consecutive times while polling; failing closed and releasing the runner." - exit 1 - fi - echo "::warning::Live pull request read failed while polling (${live_poll_failures}/${max_poll_transport_failures}); retrying after revalidation delay." - sleep "$poll_interval_seconds" - continue - fi - live_poll_failures=0 - live_poll_head="$(printf '%s' "$live_poll_pr" | jq -r '.head.sha // empty')" - live_poll_draft="$(printf '%s' "$live_poll_pr" | jq -r 'if (.draft | type) == "boolean" then (.draft | tostring) else empty end')" - live_poll_state="$(printf '%s' "$live_poll_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" - if [ -z "$live_poll_head" ] || [ -z "$live_poll_draft" ] || [ -z "$live_poll_state" ]; then - echo "::error::Could not validate live pull request state while polling for a current-head OpenCode verdict." - exit 1 - fi - if [ "$live_poll_state" != "open" ] && [ "$live_poll_state" != "closed" ]; then - echo "::error::Could not validate live pull request state while polling for a current-head OpenCode verdict." - exit 1 - fi - if [ "${live_poll_head,,}" != "${HEAD_SHA,,}" ]; then - echo "::notice::Pull request head moved while waiting for a current-head OpenCode verdict; retiring superseded Required OpenCode Review poll." - exit 1 - fi - if [ "$live_poll_state" = "closed" ]; then - echo "PR closed while waiting for the current-head OpenCode verdict; the poll is no longer required." - exit 0 - fi - if [ "$live_poll_draft" = "true" ]; then - echo "PR became draft while waiting for the current-head OpenCode verdict; the poll is no longer required until it is marked ready for review." - exit 0 - fi - if ! reviews="$(timeout 30s gh api --paginate "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")"; then - review_poll_failures=$((review_poll_failures + 1)) - if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then - echo "::error::Reviews API read failed ${review_poll_failures} consecutive times while polling; failing closed and releasing the runner." - exit 1 - fi - echo "::warning::Reviews API read failed while polling (${review_poll_failures}/${max_poll_transport_failures}); revalidating live PR state before retry." - sleep "$poll_interval_seconds" - continue - fi - review_poll_failures=0 - verdict="$(printf '%s\n' "$reviews" | jq -r -s --arg sha "$HEAD_SHA" ' + reviews="$(gh api --paginate "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")" + verdict="$(printf '%s\n' "$reviews" | jq -r -s --arg sha "$HEAD_SHA" ' (add // []) | [ .[] @@ -515,27 +504,25 @@ jobs: empty end ')" - if [ -n "$verdict" ]; then - break - fi - sleep "$poll_interval_seconds" - done if [ -z "$verdict" ]; then - echo "::error::No APPROVED or CHANGES_REQUESTED from opencode-agent on the current head. This required check is not a review and must not succeed until the authenticated dispatch posts a current-head verdict." + echo "::error::No APPROVED or CHANGES_REQUESTED from opencode-agent on the current head. The dispatch workflow will rerun this failed job after publishing an authenticated exact-head verdict." exit 1 fi echo "Current-head OpenCode verdict: ${verdict}." cancel-superseded-opencode-review-runs: - # Exact-head concurrency protects a newer authoritative run from delayed - # old-head events, while the poll above now revalidates live PR identity on - # every wait iteration so an already-running obsolete poll can self-retire - # without consuming a second runner. This sibling job remains a defense in - # depth for queued/requested old-head runs and for legacy runs created from - # older workflow revisions that lack the in-loop self-retirement check. - # Every cancellation candidate and every cancellation itself is re-verified - # against the live PR head immediately beforehand, so a cleanup run that is - # itself delayed/stale cannot cancel a still-authoritative run. + # This job -- not the bootstrap concurrency group above -- is the primary + # mechanism that actively cancels a same-PR run for an outdated head. The + # bootstrap group is now `cancel-in-progress: false` (see its own comment): + # nothing is ever preempted there, by design, to structurally close the + # #1568 stale-cancels-fresh race regardless of arrival order. This job + # achieves precise, safe "cancel only outdated runs of the same PR" + # instead: it re-verifies the live PR head immediately before selecting + # candidates AND immediately before every individual cancellation call, so + # a cleanup run that is itself delayed/stale cannot cancel a + # still-authoritative run, and it only ever targets runs whose recorded + # head no longer matches the live one. The target job also revalidates the + # live PR before dispatch and verdict admission. if: github.event_name == 'pull_request_target' && github.event.action == 'synchronize' runs-on: ubuntu-24.04 permissions: diff --git a/.github/workflows/opencode-rust-coverage-toolchain-quality-ci.yml b/.github/workflows/opencode-rust-coverage-toolchain-quality-ci.yml deleted file mode 100644 index 5e3d6c425a..0000000000 --- a/.github/workflows/opencode-rust-coverage-toolchain-quality-ci.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: OpenCode Rust Coverage Toolchain Quality CI - -on: - pull_request: - paths: - - ".github/workflows/opencode-review-dispatch.yml" - - ".github/workflows/opencode-rust-coverage-toolchain-quality-ci.yml" - - "scripts/ci/ensure_rust_llvm19.sh" - - "tests/test_opencode_rust_coverage_toolchain_contract.py" - - "tests/test_pr_review_autofix_nvidia_nim_contract.py" - - "docs/doctoring/opencode-rust-coverage-runtime-boundary.md" - - "CHANGELOG.md" - -permissions: - contents: read - -concurrency: - group: opencode-rust-coverage-toolchain-quality-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - quality: - name: quality - runs-on: ubuntu-24.04 - timeout-minutes: 15 - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 - with: - egress-policy: audit - - - name: Checkout exact pull request head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - cache: pip - cache-dependency-path: requirements-opencode-review-ci-hashes.txt - - - name: Install exact hash-locked test tooling - run: >- - python -m pip install --disable-pip-version-check --require-hashes - -r requirements-opencode-review-ci-hashes.txt - - - name: Run permanent LLVM runtime-boundary contract - run: | - set -euo pipefail - python -m pytest -q tests/test_opencode_rust_coverage_toolchain_contract.py - python -m compileall -q tests/test_opencode_rust_coverage_toolchain_contract.py - git diff --check "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" diff --git a/.github/workflows/organization-commercial-readiness-loop-quality-ci.yml b/.github/workflows/organization-commercial-readiness-loop-quality-ci.yml deleted file mode 100644 index 50729db472..0000000000 --- a/.github/workflows/organization-commercial-readiness-loop-quality-ci.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Organization Commercial Readiness Loop Quality CI - -on: - pull_request: - branches: [main] - paths: - - ".github/workflows/organization-commercial-readiness-loop.yml" - - ".github/workflows/organization-commercial-readiness-loop-quality-ci.yml" - - "scripts/ci/organization_commercial_readiness_loop.py" - - "organization_commercial_readiness_fixtures.py" - - "tests/test_organization_commercial_readiness_loop*.py" - - "docs/doctoring/organization-commercial-readiness-loop.md" - - "CHANGELOG.md" - -permissions: - contents: read - -concurrency: - group: organization-commercial-readiness-loop-quality-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - exact-head-policy: - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout exact source revision - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Install exact hash-verified quality dependencies - env: - PIP_DISABLE_PIP_VERSION_CHECK: "1" - PIP_NO_INPUT: "1" - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - cat >"${RUNNER_TEMP}/organization-loop-quality-requirements.txt" <<'EOF' - coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f - iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 - packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e - pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c - EOF - python -m pip install \ - --only-binary=:all: \ - --require-hashes \ - -r "${RUNNER_TEMP}/organization-loop-quality-requirements.txt" - - - name: Prove exact-head policy and full branch coverage - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha }}" - python -m coverage run \ - --branch \ - -m pytest --import-mode=importlib tests/test_organization_commercial_readiness_loop*.py -q - python -m coverage report \ - --include='scripts/ci/organization_commercial_readiness_loop.py' \ - --show-missing \ - --fail-under=100 - python -m compileall -q \ - scripts/ci/organization_commercial_readiness_loop.py \ - organization_commercial_readiness_fixtures.py \ - tests/test_organization_commercial_readiness_loop*.py - git diff --exit-code diff --git a/.github/workflows/osv-scanner-pr.yml b/.github/workflows/osv-scanner-pr.yml deleted file mode 100644 index e3358d9b08..0000000000 --- a/.github/workflows/osv-scanner-pr.yml +++ /dev/null @@ -1,61 +0,0 @@ -# Keeps the upstream OSV base/head diff check available on every PR. The -# central Security Scan workflow owns the blocking OSV result, finding logs, -# and SARIF upload so this supplemental check does not duplicate installation -# API calls or fail an otherwise clean PR when GitHub's upload quota is spent. -name: OSV-Scanner PR - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] - branches: [main, master, develop] - -concurrency: - group: >- - osv-scanner-pr-${{ - github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name || github.repository }}-${{ - github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -permissions: - # Scorecard Token-Permissions (alert #41): keep the workflow-level token - # read-only. SARIF upload needs security-events:write, but the osv-scan job - # below already grants it at job scope, so it is redundant (and over-broad) - # here. - actions: read - contents: read - -jobs: - osv-scan: - if: github.event.action != 'closed' - # ponytail: use upstream reusable PR workflow, don't hand-roll the diff scan - # Pinned to v2.3.8 + 1 commit (3a7550f) which gates the JSON job outputs - # behind the new `export-results` input (default false). v2.3.8 dumped the - # full old/new osv-scanner JSON into job outputs unconditionally, tripping - # GitHub's 1,048,576-byte job-outputs cap and failing the run. Same nested - # action pins as v2.3.8; only the Export step is now conditional. - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@3a7550f43ba5b58905a821ce3a0ed24c4858b3f4 # v2.3.8 + export-results gate - permissions: - actions: read - contents: read - # The pinned upstream reusable workflow declares this permission at its - # top level, so GitHub validates it even when upload-sarif is false. - security-events: write - with: - # Keep the PR code-scanning upload deterministic: direct manifest - # vulnerabilities are uploaded, but public registry rate limits cannot - # make the required upload check fail before SARIF reaches GitHub. - # The security-scan workflow still performs the full base/head OSV pass - # first and logs its --no-resolve fallback reason when registries are - # transiently unavailable. - scan-args: |- - --maven-registry=https://maven-central.storage-download.googleapis.com/maven2 - --no-resolve - -r - ./ - # The required central security-scan.yml job uploads the comprehensive - # current-head OSV SARIF. Avoid a second upload through the reusable - # workflow because installation rate-limit failures are not findings. - upload-sarif: false - # Merge gating is done by central security-scan.yml with - # --fail-on-vuln=true after printing package, version, OSV ID and aliases. - fail-on-vuln: false \ No newline at end of file diff --git a/.github/workflows/pr-review-autofix.yml b/.github/workflows/pr-review-autofix.yml index 505384ccfd..1b7849a0c5 100644 --- a/.github/workflows/pr-review-autofix.yml +++ b/.github/workflows/pr-review-autofix.yml @@ -22,7 +22,7 @@ permissions: jobs: autofix: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # No job-level timeout-minutes here, deliberately. This job's dominant # cost is `opencode run` (up to two invocations: the main autofix pass, # and a base-merge conflict-resolution pass) -- a job-level wall-clock diff --git a/.github/workflows/pr-review-fix-scheduler.yml b/.github/workflows/pr-review-fix-scheduler.yml index cc9d3e60ed..0c0c05c151 100644 --- a/.github/workflows/pr-review-fix-scheduler.yml +++ b/.github/workflows/pr-review-fix-scheduler.yml @@ -18,6 +18,16 @@ on: required: false default: "1" type: string + scan_window_size: + description: Maximum PRs to deeply inspect in one scheduler run + required: false + default: "50" + type: string + rotation_seed: + description: Deterministic seed selecting the bounded PR scan window + required: false + default: "0" + type: string target_repository: description: Repository to scan, in owner/name form; defaults to the caller repository required: false @@ -79,7 +89,7 @@ permissions: jobs: dispatch-review-fixes: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 35 env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true @@ -88,6 +98,8 @@ jobs: DRY_RUN: ${{ github.event.client_payload.dry_run == true || github.event.client_payload.dry_run == 'true' || inputs.dry_run == true }} MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '50' }} MAX_DISPATCHES: ${{ github.event.client_payload.max_dispatches || inputs.max_dispatches || '1' }} + SCAN_WINDOW_SIZE: ${{ github.event.client_payload.scan_window_size || inputs.scan_window_size || '50' }} + ROTATION_SEED: ${{ github.event.client_payload.rotation_seed || inputs.rotation_seed || '0' }} RESOLVE_UNREVIEWED_CONFLICTS: ${{ github.event.client_payload.resolve_unreviewed_conflicts == true || github.event.client_payload.resolve_unreviewed_conflicts == 'true' || inputs.resolve_unreviewed_conflicts == true }} RETRY_HOURS: ${{ github.event.client_payload.retry_hours || inputs.retry_hours || '1' }} AUTOFIX_WORKFLOW: pr-review-autofix.yml @@ -141,9 +153,22 @@ jobs: # Only the direct repository_dispatch surface needs sender binding; # cross-repository invocations still pass the configured allowlist. if [ "$EVENT_NAME" = "repository_dispatch" ]; then - if [ -z "$ALLOWED_DISPATCH_ACTOR" ] || - [ "$DISPATCH_ACTOR" != "$ALLOWED_DISPATCH_ACTOR" ] || - [ "$DISPATCH_SENDER" != "$ALLOWED_DISPATCH_ACTOR" ]; then + # ALLOWED_DISPATCH_ACTOR is a comma-separated allowlist shared with + # opencode-review-dispatch.yml and codeql-scan-dispatch.yml; all + # three parse it the same way. Actor AND sender must both equal the + # SAME listed identity, and an empty allowlist admits nothing. + actor_allowed=0 + IFS=',' read -r -a allowed_dispatch_actors <<<"$ALLOWED_DISPATCH_ACTOR" + for allowed_actor in "${allowed_dispatch_actors[@]}"; do + allowed_actor="${allowed_actor//[[:space:]]/}" + if [ -n "$allowed_actor" ] && + [ "$DISPATCH_ACTOR" = "$allowed_actor" ] && + [ "$DISPATCH_SENDER" = "$allowed_actor" ]; then + actor_allowed=1 + break + fi + done + if [ "$actor_allowed" -ne 1 ]; then echo "::error::Scheduler repository dispatch actor or sender is unauthorized." exit 1 fi @@ -318,6 +343,8 @@ jobs: --base-branch "$DEFAULT_BRANCH" --max-prs "$MAX_PRS" --max-dispatches "$MAX_DISPATCHES" + --scan-window-size "$SCAN_WINDOW_SIZE" + --rotation-seed "$ROTATION_SEED" --retry-hours "$RETRY_HOURS" --autofix-workflow "$AUTOFIX_WORKFLOW" --autofix-repository "$AUTOFIX_REPOSITORY" diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index 718f307d71..d32918cf45 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -4,12 +4,9 @@ on: push: branches: [main, develop, master] pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, auto_merge_enabled, closed] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, auto_merge_enabled, closed] pull_request_review: types: [submitted, dismissed] - workflow_run: - workflows: ["Required OpenCode Review", "Strix Security Scan"] - types: [completed] workflow_call: inputs: dry_run: @@ -73,32 +70,9 @@ on: default: "" type: string schedule: - # scan-pr-queue's own repository-local heartbeat. org-queue-sweep below - # explicitly excludes ContextualWisdomLab/.github from its target list - # (a PR in THIS repository, including one editing the governance - # workflows themselves, is never covered by the org-wide sweep), so this - # is the only periodic fallback for this repository's own PR queue. It - # also plugs a real event-coverage gap shared by every repository: - # required checks such as Security Scan and SAST Semgrep have no - # workflow_run listener anywhere in this file, so a PR where either is - # the last required check to go green has no event-driven re-wake at - # all. Offset by 30 minutes from org-queue-sweep's "0 * * * *" tick so - # the two heartbeats do not collide. Lengthened from */30 to hourly for - # the same Actions-capacity reason, and by the same lever, as the - # org-queue-sweep hourly cadence below (see - # docs/doctoring/actions-queue-saturation-hourly-sweep.md, #1630) — do - # NOT remove it outright; that would leave this repository's own queue - # with zero fallback heartbeat. - - cron: "30 * * * *" - # Hourly org-wide sweep cadence for the org-queue-sweep job below. Target - # repositories only receive scheduler runs on PR events, review/security - # workflow completion, and protected-branch pushes; a PR whose approval or - # required checks land AFTER its last event has no later trigger and sits - # approved-but-unmerged until a human pushes something. The sweep closes - # that gap on a fixed heartbeat. Runs hourly so an approval or - # required check that lands after a PR's last event is auto-updated/merged - # within about an hour without adding quarter-hourly runner pressure. - - cron: "0 * * * *" + # Daily missed-event recovery for this repository. Native PR/review events + # own the normal path; auto-merge handles required-check completion. + - cron: "47 3 * * *" repository_dispatch: types: [merge-scheduler] @@ -107,17 +81,14 @@ concurrency: central-pr-review-merge-scheduler-${{ github.repository }}-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'pull_request_review' && format('pr-{0}', github.event.pull_request.number) || - github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0].number && format('pr-{0}', github.event.workflow_run.pull_requests[0].number) || - github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number && format('workflow-run-no-pr-{0}', github.repository) || github.event_name == 'workflow_call' && inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.event_name == 'workflow_call' && inputs.base_branch != '' && format('call-{0}', inputs.base_branch) || github.event_name == 'schedule' && format('schedule-{0}', github.event.schedule) || - github.event_name == 'repository_dispatch' && github.event.client_payload.org_sweep == true && format('org-sweep-{0}', github.repository) || github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.pr_number != '' && format('target-{0}-pr-{1}', github.event.client_payload.target_repository, github.event.client_payload.pr_number) || github.event_name == 'repository_dispatch' && github.event.client_payload.pr_number != '' && format('pr-{0}', github.event.client_payload.pr_number) || github.event_name == 'repository_dispatch' && format('repo-dispatch-{0}', github.repository) || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' || (github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number) }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || github.event_name == 'repository_dispatch' }} # Scorecard Token-Permissions (alert #9): declare a least-privilege default at # the workflow level. The scan-pr-queue job that actually needs write access @@ -130,24 +101,11 @@ jobs: scan-pr-queue: # repository_dispatch review runs do not reliably carry pull_requests metadata. # Without this guard, one completed central review can wake a repo-wide scan. - # The org-sweep cron and org_sweep dispatches are handled by org-queue-sweep - # below; skipping them here avoids a duplicate same-repository scan. if: >- ( github.event_name != 'pull_request_target' || github.event.action != 'closed' ) && - ( - github.event_name != 'workflow_run' || - ( - github.event.workflow_run.conclusion != 'cancelled' && - github.event.workflow_run.pull_requests[0].number - ) - ) && - ( - github.event_name != 'schedule' || - github.event.schedule != '0 * * * *' - ) && ( github.event_name != 'repository_dispatch' || github.event.client_payload.org_sweep != true @@ -156,9 +114,7 @@ jobs: # Bound scan-pr-queue to a wall-clock ceiling well short of GitHub's # 360-minute platform default. This is a single-repository queue scan # (paginated GraphQL reads plus at most one review dispatch and one - # branch update per run) -- much lighter than org-queue-sweep's full - # organization walk below, so it gets a shorter bound than that job's - # timeout-minutes: 60. + # branch update per run), so it stays well below GitHub's platform default. timeout-minutes: 30 permissions: actions: write @@ -173,13 +129,14 @@ jobs: DRY_RUN: ${{ github.event.client_payload.dry_run == true || inputs.dry_run == true }} MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || '100' }} PROJECT_FLOW_INPUT: ${{ github.event.client_payload.project_flow || inputs.project_flow || vars.PROJECT_FLOW || '' }} - PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || github.event.workflow_run.pull_requests[0].number || github.event.client_payload.pr_number || inputs.pr_number || '' }} - TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || (github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false) || inputs.trigger_reviews == true }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number || github.event.client_payload.pr_number || inputs.pr_number || '' }} + TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review' || (github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false) || inputs.trigger_reviews == true }} REVIEW_DISPATCH_LIMIT_INPUT: ${{ github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || vars.REVIEW_DISPATCH_LIMIT || '1' }} + REVIEW_ADMISSION_DISPATCH_BUDGET: ${{ vars.REVIEW_ADMISSION_DISPATCH_BUDGET || '1' }} BRANCH_UPDATE_LIMIT_INPUT: ${{ github.event.client_payload.branch_update_limit || inputs.branch_update_limit || vars.BRANCH_UPDATE_LIMIT || '1' }} - ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false) || inputs.enable_auto_merge == true }} + ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || (github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false) || inputs.enable_auto_merge == true }} MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || vars.PR_MERGE_MODE || 'direct_or_auto' }} - UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || (github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false) || inputs.update_branches == true }} + UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || (github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false) || inputs.update_branches == true }} STALE_OPENCODE_MINUTES: ${{ github.event.client_payload.stale_opencode_minutes || inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '90' }} steps: - name: Exchange OpenCode app token for scheduler mutations @@ -395,6 +352,27 @@ jobs: "${api_url}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1 test -f scripts/ci/pr_review_merge_scheduler.py + test -f scripts/ci/current_head_run_coalescer.py + + - name: Retire redundant queued exact-head runs + if: >- + github.repository == 'ContextualWisdomLab/.github' + && github.event_name == 'pull_request_target' + env: + COALESCE_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + EXPECTED_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + EXPECTED_HEAD_REF: ${{ github.event.pull_request.head.ref }} + EXPECTED_HEAD: ${{ github.event.pull_request.head.sha }} + shell: bash + run: | + set -euo pipefail + python3 scripts/ci/current_head_run_coalescer.py \ + --repo "$COALESCE_REPO" \ + --pr-number "$PR_NUMBER" \ + --expected-head-repo "$EXPECTED_HEAD_REPO" \ + --expected-head-ref "$EXPECTED_HEAD_REF" \ + --expected-head "$EXPECTED_HEAD" - name: Self-test scheduler run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test @@ -499,7 +477,7 @@ jobs: done if [ "$opencode_state" != "success" ]; then - printf '::warning::Post-approval direct-merge follow-up skipped because the approved OpenCode publication run did not complete successfully. PR=%s head=%s state=%s reason=%s. The scheduled organization sweep remains authoritative.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$opencode_state" "$opencode_reason" + printf '::warning::Post-approval direct-merge follow-up skipped because the approved OpenCode publication run did not complete successfully. PR=%s head=%s state=%s reason=%s. Native events and the explicit org-sweep recovery remain authoritative.\n' "$REVIEW_PR_NUMBER" "$REVIEW_HEAD_SHA" "$opencode_state" "$opencode_reason" echo "proceed=false" >>"$GITHUB_OUTPUT" fi @@ -561,6 +539,9 @@ jobs: --project-flow "$project_flow" --review-workflow "Required OpenCode Review" --review-dispatch-limit "$review_dispatch_limit" + --admission-state-path "${RUNNER_TEMP}/review-admission/state.json" + --admission-dispatch-budget "$REVIEW_ADMISSION_DISPATCH_BUDGET" + --admission-sequence "$GITHUB_RUN_ID" --branch-update-limit "$branch_update_limit" --stale-opencode-minutes "$STALE_OPENCODE_MINUTES" ) @@ -587,702 +568,3 @@ jobs: args+=(--no-update-branches) fi python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}" - - org-queue-sweep: - # Organization-wide approved-PR fallback sweep. Event-driven scheduler runs - # in target repositories stop retrying once their triggering event is - # consumed, so a PR that becomes mergeable AFTER its last event (approval - # published after the scheduler pass, required merge-preview checks landing - # late, a base-branch policy blocker clearing) stays approved-but-unmerged - # with no later trigger. This job re-runs the same trusted scheduler against - # every organization repository on an hourly heartbeat so each such PR is - # merged, branch-updated, or leaves a concrete per-PR blocker reason in this - # log. It never bypasses policy: all mutations go through the same guarded - # scheduler contract as the per-repository runs. Stacked PRs have no - # injected required workflow, so they receive a separate bounded OpenCode - # dispatch budget and cannot be starved by the ordinary queue. - if: >- - github.repository == 'ContextualWisdomLab/.github' && - ( - (github.event_name == 'schedule' && github.event.schedule == '0 * * * *') || - (github.event_name == 'repository_dispatch' && github.event.client_payload.org_sweep == true) - ) - runs-on: ubuntu-24.04 - # The complete organization walk exceeded the legacy 30-minute boundary in - # production. Keep one running and one latest pending hourly sweep through the - # schedule-specific concurrency key above, while allowing the current walk - # enough time to finish instead of cancelling before later repositories. - timeout-minutes: 60 - permissions: - actions: write - checks: read - contents: write - id-token: write - pull-requests: write - env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - GH_TOKEN: ${{ github.token }} - DRY_RUN: ${{ github.event.client_payload.dry_run == true || inputs.dry_run == true }} - ORG_SWEEP_OWNER: ContextualWisdomLab - # Inspect the complete practical queue for every repository. The previous - # default of 30 silently omitted older PRs whenever a repository had a - # larger queue (BandScope had 34 during the incident that established - # this contract). The scheduler paginates, so 1000 keeps the practical - # GitHub queue ceiling while avoiding an arbitrary per-repository sample. - ORG_SWEEP_MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || vars.ORG_SWEEP_MAX_PRS || '1000' }} - ORG_SWEEP_REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || vars.ORG_SWEEP_REVIEW_DISPATCH_LIMIT || '1' }} - ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.stacked_review_dispatch_limit || vars.ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT || '1' }} - ORG_SWEEP_BRANCH_UPDATE_LIMIT: ${{ github.event.client_payload.branch_update_limit || inputs.branch_update_limit || vars.ORG_SWEEP_BRANCH_UPDATE_LIMIT || '1' }} - ORG_SWEEP_TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false || inputs.trigger_reviews == true }} - ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }} - ORG_SWEEP_MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || 'direct_or_auto' }} - ORG_SWEEP_UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false || inputs.update_branches == true }} - ORG_SWEEP_STALE_QUEUE_HOURS: ${{ vars.ORG_SWEEP_STALE_QUEUE_HOURS || '24' }} - # The review-dispatch, stacked-review, and branch-update budgets above are organization-wide - # per sweep tick (sized to bound LLM review-provider cost/rate exposure, not - # per-repository). Without rotation, `sweep_targets` is walked in a fixed - # order every tick (the org repos API response order), so the same early - # repositories always exhaust a queue's budget and every later repository - # starves indefinitely even with zero-open-thread, all-green PRs - # (ContextualWisdomLab/.github#1219). Left unset here so the sweep step - # below derives it from a persistent per-execution counter (or, as a - # fallback, wall-clock time) instead of `github.run_number`: run_number - # increments on every trigger of this workflow (push, - # pull_request_target, pull_request_review, workflow_run), not only the - # sweep schedule, so it cannot give the "bounded by repository_count - # ticks" guarantee a rotation is meant to provide. Wall-clock time alone - # is also insufficient, since this single-flight/non-cancelling job can - # run up to 60 minutes and a delayed real execution can let more than - # one hourly window elapse, occasionally repeating a modulo offset - # (ContextualWisdomLab/.github#1223 review finding). - # A repository the sweep credential structurally cannot read (the OpenCode - # app is not installed there / the PR_REVIEW_MERGE_TOKEN lacks it) returns - # HTTP 403 "Resource not accessible by integration". That is an access-grant - # fact the automation can never resolve, so it is reported as a skipped, - # non-fatal "unavailable" repository rather than a hard sweep failure. This - # ceiling keeps the sweep fail-closed against a credential-scope regression: - # if MORE than this many repositories become unreachable at once, the whole - # credential likely broke and the job fails loudly. - ORG_SWEEP_MAX_UNAVAILABLE: ${{ vars.ORG_SWEEP_MAX_UNAVAILABLE || '5' }} - STALE_OPENCODE_MINUTES: ${{ github.event.client_payload.stale_opencode_minutes || inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '90' }} - steps: - - name: Exchange OpenCode app token for sweep mutations - id: sweep_app_token - env: - OIDC_AUDIENCE: opencode-github-action - OPENCODE_API_BASE_URL: https://api.opencode.ai - run: | - set -euo pipefail - - mark_unavailable() { - echo "available=false" >>"$GITHUB_OUTPUT" - } - - if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then - echo "OpenCode app token exchange unavailable: OIDC request environment is missing." - mark_unavailable - exit 0 - fi - - request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" - separator="&" - case "$request_url" in - *\?*) ;; - *) separator="?" ;; - esac - - if ! oidc_response="$( - curl -fsS \ - -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ - "${request_url}${separator}audience=${OIDC_AUDIENCE}" - )"; then - echo "OpenCode app token exchange unavailable: OIDC token request did not complete." - mark_unavailable - exit 0 - fi - - oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" - if [ -z "$oidc_token" ]; then - echo "OpenCode app token exchange unavailable: OIDC token response was empty." - mark_unavailable - exit 0 - fi - - if ! token_response="$( - curl -fsS \ - -X POST \ - -H "Authorization: Bearer ${oidc_token}" \ - "${OPENCODE_API_BASE_URL}/exchange_github_app_token" - )"; then - echo "OpenCode app token exchange unavailable: app token request did not complete." - mark_unavailable - exit 0 - fi - - app_token="$(jq -r '.token // empty' <<<"$token_response")" - if [ -z "$app_token" ]; then - echo "OpenCode app token exchange unavailable: app token response was empty." - mark_unavailable - exit 0 - fi - - echo "::add-mask::$app_token" - { - echo "available=true" - echo "token=$app_token" - } >>"$GITHUB_OUTPUT" - - - name: Resolve trusted scheduler source ref - id: trusted_source - env: - JOB_CONTEXT_JSON: ${{ toJSON(job) }} - GITHUB_CONTEXT_JSON: ${{ toJSON(github) }} - run: | - set -euo pipefail - python3 <<'PY' >>"$GITHUB_OUTPUT" - import json - import os - import re - import sys - - try: - job_context = json.loads(os.environ.get("JOB_CONTEXT_JSON") or "{}") - github_context = json.loads(os.environ.get("GITHUB_CONTEXT_JSON") or "{}") - except json.JSONDecodeError as exc: - print(f"::error::Could not parse GitHub workflow context JSON: {exc}", file=sys.stderr) - raise SystemExit(1) - - trusted_repository = str( - job_context.get("workflow_repository") or "ContextualWisdomLab/.github" - ).strip() - trusted_ref = str( - job_context.get("workflow_sha") or github_context.get("workflow_sha") or "" - ).strip() - workflow_ref = str( - job_context.get("workflow_ref") or github_context.get("workflow_ref") or "" - ).strip() - - if not trusted_ref: - trusted_ref = "main" - prefix = "ContextualWisdomLab/.github/.github/workflows/pr-review-merge-scheduler.yml@" - if workflow_ref.startswith(prefix): - trusted_ref = workflow_ref.split("@", 1)[1] - - if trusted_repository != "ContextualWisdomLab/.github": - print("::error::Trusted scheduler workflow repository resolved outside ContextualWisdomLab/.github.", file=sys.stderr) - raise SystemExit(1) - if not re.fullmatch(r"[0-9a-fA-F]{40}|refs/[^\s]+|[A-Za-z0-9._/-]+", trusted_ref): - print("::error::Trusted scheduler workflow ref resolved to an invalid value.", file=sys.stderr) - raise SystemExit(1) - - print(f"repository={trusted_repository}") - print(f"ref={trusted_ref}") - PY - - - name: Materialize trusted scheduler - env: - GH_TOKEN: ${{ github.token }} - TRUSTED_SOURCE_REF: ${{ steps.trusted_source.outputs.ref }} - run: | - set -euo pipefail - if [[ ! "$TRUSTED_SOURCE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Trusted scheduler source ref must resolve to the immutable workflow commit SHA before archive materialization." - exit 1 - fi - trusted_archive="${RUNNER_TEMP}/trusted-scheduler-source.tar.gz" - api_url="${GITHUB_API_URL:-https://api.github.com}" - curl -fsSL \ - -H "Authorization: Bearer ${GH_TOKEN}" \ - -H "Accept: application/vnd.github+json" \ - -o "$trusted_archive" \ - "${api_url}/repos/ContextualWisdomLab/.github/tarball/${TRUSTED_SOURCE_REF}" - tar -xzf "$trusted_archive" -C "$GITHUB_WORKSPACE" --strip-components=1 - test -f scripts/ci/pr_review_merge_scheduler.py - - - name: Self-test scheduler - run: python3 scripts/ci/pr_review_merge_scheduler.py --self-test - - - name: Sweep organization repository queues - env: - GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_app_token.outputs.token || github.token }} - SCHEDULER_ACTIONS_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.sweep_app_token.outputs.token || github.token }} - # The sweep executes inside ContextualWisdomLab/.github, which is exactly - # where the central required workflows are dispatched, so the runner's own - # github.token (contents: write) is a sufficient dispatch credential even - # though the OpenCode app token has no Actions permission. Without this the - # sweep deadlocks every PR that needs current-head review evidence with - # "no cross-repository repository-dispatch credential". - SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} - SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.sweep_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} - SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY: ContextualWisdomLab/.github - SCHEDULER_ALLOW_CROSS_REPO_REPOSITORY_DISPATCH: ${{ (secrets.PR_REVIEW_MERGE_TOKEN != '' || secrets.OPENCODE_APPROVE_TOKEN != '') && 'true' || 'false' }} - run: | - set -euo pipefail - case "$STALE_OPENCODE_MINUTES" in - ''|*[!0-9]*) - echo "::error::STALE_OPENCODE_MINUTES must contain only decimal digits" - exit 1 - ;; - esac - if [ "${#STALE_OPENCODE_MINUTES}" -gt 4 ]; then - echo "::error::STALE_OPENCODE_MINUTES must be between 1 and 1440" - exit 1 - fi - stale_opencode_minutes=$((10#$STALE_OPENCODE_MINUTES)) - if [ "$stale_opencode_minutes" -lt 1 ] || [ "$stale_opencode_minutes" -gt 1440 ]; then - echo "::error::STALE_OPENCODE_MINUTES must be between 1 and 1440" - exit 1 - fi - STALE_OPENCODE_MINUTES="$stale_opencode_minutes" - if [ "$SCHEDULER_MUTATION_TOKEN_SOURCE" = "github-token" ]; then - # github.token is repository-scoped to .github and cannot mutate - # sibling repositories; a sweep with it would silently do nothing. - echo "::error::Organization queue sweep has no cross-repository mutation credential. Configure the PR_REVIEW_MERGE_TOKEN or OPENCODE_APPROVE_TOKEN secret (or keep the OpenCode app token exchange available) so approved PRs in target repositories can be merged or updated." - exit 1 - fi - echo "Sweep mutation token source: $SCHEDULER_MUTATION_TOKEN_SOURCE" - - # Validate the fail-closed ceiling before it is used in a numeric test. - # A non-integer would make "[ ... -gt ... ]" error out inside an if - # condition, which set -e does not trap, silently skipping the - # regression guard. Fail loudly instead so a misconfigured - # ORG_SWEEP_MAX_UNAVAILABLE can never quietly disable fail-closed. - if ! [[ "$ORG_SWEEP_MAX_UNAVAILABLE" =~ ^[0-9]+$ ]]; then - echo "::error::ORG_SWEEP_MAX_UNAVAILABLE must be a non-negative integer; got '${ORG_SWEEP_MAX_UNAVAILABLE}'. Fix the ORG_SWEEP_MAX_UNAVAILABLE repository variable." - exit 1 - fi - if ! [[ "$ORG_SWEEP_REVIEW_DISPATCH_LIMIT" =~ ^(-1|[0-9]+)$ ]]; then - echo "::error::ORG_SWEEP_REVIEW_DISPATCH_LIMIT must be -1 or a non-negative integer; got '${ORG_SWEEP_REVIEW_DISPATCH_LIMIT}'. Fix the ORG_SWEEP_REVIEW_DISPATCH_LIMIT repository variable." - exit 1 - fi - if ! [[ "$ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT" =~ ^(-1|[0-9]+)$ ]]; then - echo "::error::ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT must be -1 or a non-negative integer; got '${ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT}'. Fix the ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT repository variable." - exit 1 - fi - if ! [[ "$ORG_SWEEP_BRANCH_UPDATE_LIMIT" =~ ^(-1|[0-9]+)$ ]]; then - echo "::error::ORG_SWEEP_BRANCH_UPDATE_LIMIT must be -1 or a non-negative integer; got '${ORG_SWEEP_BRANCH_UPDATE_LIMIT}'. Fix the ORG_SWEEP_BRANCH_UPDATE_LIMIT repository variable." - exit 1 - fi - # Unset in production (see the env-block comment above). Primary - # source: a persistent `ORG_SWEEP_ROTATION_COUNTER` repository - # variable on this (.github) repository, incremented by exactly - # one at the start of every actual org-queue-sweep execution. A - # wall-clock tick (one per hour) is *not* sufficient on its own: - # this job is single-flight/non-cancelling with up to a 60-minute - # timeout, so a delayed or backlogged execution can let more than - # one hourly window elapse between two real sweep runs, and if that - # gap happens to be an exact multiple of the repository count the - # modulo offset repeats -- reintroducing the exact starvation - # #1220 fixed (CodeRabbit review finding on #1223). A persistent - # per-execution counter advances by exactly one every time the - # sweep body actually runs, regardless of how much wall-clock time - # a slow prior run consumed. Falls back to the wall-clock tick, - # which still strictly improves on the pre-#1220 fixed order, only - # if the counter read/write itself is unavailable (permissions, - # transient API failure) -- a fairness mechanism must never fail - # the sweep's much more important review-dispatch/merge work. - # Tests inject ORG_SWEEP_ROTATION_INDEX directly for determinism, - # which this only fills in when absent. - # - # Two known, accepted limitations of this counter (Devin review on - # #1223), neither of which is fixed here: - # - Read-modify-write is not atomic. A schedule-triggered run and a - # manual `repository_dispatch` org_sweep run use different - # concurrency groups and can therefore execute concurrently, in - # which case both could read the same counter value and pick the - # same rotation offset for that one pair of runs. The REST - # Variables API has no compare-and-swap primitive to close this - # without a broader concurrency-group redesign shared across - # every trigger type this workflow serves; the consequence is - # bounded and self-correcting (one occasionally-repeated offset, - # not a stuck one), so it is accepted rather than redesigned. - # - Whether the PATCH/POST below ever succeeds in production - # depends on the resolved token actually holding repository - # Variables-write scope, which is not independently verifiable - # from inside this workflow. If it does not, every run silently - # but safely degrades to the wall-clock fallback below (logged - # via ::warning:: each time), which is still strictly better - # than the pre-#1220 fixed order -- never a hard failure, and - # observable in the run log for whoever holds that token. - if [ -z "${ORG_SWEEP_ROTATION_INDEX:-}" ]; then - counter_variable_name="ORG_SWEEP_ROTATION_COUNTER" - # Distinguish a *successful* read (the variable exists; its - # value, valid or not, is authoritative) from a *failed* read - # (transient error, permissions, or the variable genuinely - # doesn't exist yet -- indistinguishable from here). Only a - # successful read may PATCH: a transient failure that silently - # became "treat as 0" would let the PATCH below clobber an - # already-accumulated counter value back down to 1, restarting - # the rotation sequence instead of degrading to the wall-clock - # fallback the design intends (Devin review finding on #1223). - if counter_current="$( - gh api "repos/${GITHUB_REPOSITORY}/actions/variables/${counter_variable_name}" \ - --jq '.value' 2>/dev/null - )"; then - if ! [[ "$counter_current" =~ ^[0-9]+$ ]]; then - counter_current=0 - fi - # Force base-10: a manually-seeded value with a leading zero - # (e.g. "08") passes the digit-only check above but bash's - # unprefixed arithmetic parses a leading-zero literal as - # octal, and "08"/"09" are not valid octal digits -- errors - # under set -e. $((10#...)) is the same guard already used - # elsewhere in this file (STALE_OPENCODE_MINUTES). - counter_next=$(( 10#$counter_current + 1 )) - if gh api "repos/${GITHUB_REPOSITORY}/actions/variables/${counter_variable_name}" \ - -X PATCH -f "value=${counter_next}" >/dev/null 2>&1; then - ORG_SWEEP_ROTATION_INDEX="$counter_next" - else - echo "::warning::read ${counter_variable_name}=${counter_current} but could not PATCH it; falling back to a wall-clock rotation tick for this run only" - ORG_SWEEP_ROTATION_INDEX=$(( $(date -u +%s) / 3600 )) - fi - elif gh api "repos/${GITHUB_REPOSITORY}/actions/variables" \ - -X POST -f "name=${counter_variable_name}" -f "value=1" >/dev/null 2>&1; then - # The read failed, so this is only safe as a first-run - # create: POST fails on its own if the variable actually - # already exists (a real read outage rather than a genuinely - # missing variable), which correctly falls through to the - # wall-clock branch below instead of resetting a value this - # run could not see. - ORG_SWEEP_ROTATION_INDEX=1 - else - echo "::warning::could not read/write ${counter_variable_name}; falling back to a wall-clock rotation tick for this run only" - ORG_SWEEP_ROTATION_INDEX=$(( $(date -u +%s) / 3600 )) - fi - fi - if ! [[ "$ORG_SWEEP_ROTATION_INDEX" =~ ^[0-9]+$ ]]; then - echo "::error::ORG_SWEEP_ROTATION_INDEX must be a non-negative integer; got '${ORG_SWEEP_ROTATION_INDEX}'." - exit 1 - fi - - repositories_json="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "/orgs/${ORG_SWEEP_OWNER}/repos?per_page=100&type=all" --paginate - )" - mapfile -t sweep_targets < <( - jq -r ' - .[] - | select(.archived == false and .disabled == false) - | select(.full_name != "ContextualWisdomLab/.github") - | "\(.full_name)\t\(.default_branch)" - ' <<<"$repositories_json" - ) - sweep_target_count=${#sweep_targets[@]} - # Rotate the fixed walk order by ORG_SWEEP_ROTATION_INDEX (see - # above: a persistent per-execution counter, falling back to a - # wall-clock tick) so the same organization-wide review-dispatch - # /branch-update budgets land on a different starting repository each - # execution instead of always exhausting on the same early - # repositories (#1219). The ordinary and stacked review budgets are - # tracked independently so the latter cannot be starved by the former. - rotation_offset=0 - if [ "$sweep_target_count" -gt 0 ]; then - rotation_offset=$(( ORG_SWEEP_ROTATION_INDEX % sweep_target_count )) - if [ "$rotation_offset" -gt 0 ]; then - sweep_targets=( - "${sweep_targets[@]:rotation_offset}" - "${sweep_targets[@]:0:rotation_offset}" - ) - fi - fi - echo "Sweeping ${sweep_target_count} repositories starting at rotation offset ${rotation_offset} (rotation tick ${ORG_SWEEP_ROTATION_INDEX})." - - failures=0 - unavailable=0 - unavailable_repos=() - # These are organization-wide budgets. They must be consumed across - # the repository loop, not reset for every target repository; resetting - # them here can enqueue hundreds of long-running review jobs per sweep. - org_review_dispatches_used=0 - org_stacked_review_dispatches_used=0 - org_branch_updates_used=0 - for target in "${sweep_targets[@]}"; do - repo_full_name="${target%%$'\t'*}" - default_branch="${target##*$'\t'}" - echo "::group::Sweep ${repo_full_name} (base ${default_branch})" - - open_pr_count="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "/repos/${repo_full_name}/pulls?state=open&per_page=1" \ - --jq 'length' || echo "unknown" - )" - if [ "$open_pr_count" = "0" ]; then - echo "No open PRs (including stacked or non-default-base PRs); skipping." - echo "::endgroup::" - continue - fi - - # The scheduler requires --project-flow. Derive it per target the - # same way the single-repository job does: main/master default - # branches are GitHub Flow, develop is Git Flow, anything else - # defaults to GitHub Flow. - case "$default_branch" in - main|master) project_flow="github-flow" ;; - develop) project_flow="git-flow" ;; - *) project_flow="github-flow" ;; - esac - - if [ "$ORG_SWEEP_REVIEW_DISPATCH_LIMIT" = "-1" ]; then - review_dispatch_limit=-1 - else - review_dispatch_limit=$((ORG_SWEEP_REVIEW_DISPATCH_LIMIT - org_review_dispatches_used)) - if (( review_dispatch_limit < 0 )); then - review_dispatch_limit=0 - fi - fi - if [ "$ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT" = "-1" ]; then - stacked_review_dispatch_limit=-1 - else - stacked_review_dispatch_limit=$((ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT - org_stacked_review_dispatches_used)) - if (( stacked_review_dispatch_limit < 0 )); then - stacked_review_dispatch_limit=0 - fi - fi - if [ "$ORG_SWEEP_BRANCH_UPDATE_LIMIT" = "-1" ]; then - branch_update_limit=-1 - else - branch_update_limit=$((ORG_SWEEP_BRANCH_UPDATE_LIMIT - org_branch_updates_used)) - if (( branch_update_limit < 0 )); then - branch_update_limit=0 - fi - fi - - args=( - --repo "$repo_full_name" - --base-branch "$default_branch" - --project-flow "$project_flow" - --max-prs "$ORG_SWEEP_MAX_PRS" - --review-workflow "Required OpenCode Review" - --review-dispatch-limit "$review_dispatch_limit" - --stacked-review-dispatch-limit "$stacked_review_dispatch_limit" - --branch-update-limit "$branch_update_limit" - --stale-opencode-minutes "$STALE_OPENCODE_MINUTES" - --merge-mode "$ORG_SWEEP_MERGE_MODE" - ) - if [ "$ORG_SWEEP_TRIGGER_REVIEWS" = "true" ]; then - args+=(--trigger-reviews) - fi - if [ "$ORG_SWEEP_ENABLE_AUTO_MERGE" = "true" ]; then - args+=(--enable-auto-merge) - fi - if [ "$ORG_SWEEP_UPDATE_BRANCHES" = "true" ]; then - args+=(--update-branches) - fi - if [ "$DRY_RUN" = "true" ]; then - args+=(--dry-run) - fi - set +e - sweep_output="$(python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}" 2>&1)" - sweep_rc=$? - set -e - printf '%s\n' "$sweep_output" - repo_stacked_review_dispatches="$(printf '%s\n' "$sweep_output" | grep -Ec '^PR #[0-9]+: review_dispatch: stacked PR onto' || true)" - repo_review_dispatches_total="$(printf '%s\n' "$sweep_output" | grep -Ec '^PR #[0-9]+: (review_dispatch|security_dispatch):' || true)" - repo_review_dispatches=$((repo_review_dispatches_total - repo_stacked_review_dispatches)) - repo_branch_updates="$(printf '%s\n' "$sweep_output" | grep -Ec '^PR #[0-9]+: (update_branch|restamp_head):' || true)" - org_review_dispatches_used=$((org_review_dispatches_used + repo_review_dispatches)) - org_stacked_review_dispatches_used=$((org_stacked_review_dispatches_used + repo_stacked_review_dispatches)) - org_branch_updates_used=$((org_branch_updates_used + repo_branch_updates)) - echo "Org sweep budget consumed: review dispatches=${org_review_dispatches_used}/${ORG_SWEEP_REVIEW_DISPATCH_LIMIT}, stacked review dispatches=${org_stacked_review_dispatches_used}/${ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT}, branch updates=${org_branch_updates_used}/${ORG_SWEEP_BRANCH_UPDATE_LIMIT}." - if [ "$sweep_rc" -ne 0 ]; then - # A structural access denial ("Resource not accessible by - # integration") means the sweep credential cannot read this - # repository at all — the OpenCode app is not installed there or - # PR_REVIEW_MERGE_TOKEN does not cover it. The automation can never - # merge those PRs regardless, so this is a skipped, non-fatal - # "unavailable" repository, not a failure the sweep can act on. Any - # other non-zero exit is a genuine per-repository failure. - if printf '%s' "$sweep_output" | grep -qF "Resource not accessible by integration"; then - echo "::warning::Skipping ${repo_full_name}: the sweep credential lacks access (HTTP 403 Resource not accessible by integration). Install the OpenCode app on this repository or grant PR_REVIEW_MERGE_TOKEN access to include it in the sweep." - unavailable=$((unavailable + 1)) - unavailable_repos+=("$repo_full_name") - else - echo "::error::Queue sweep failed for ${repo_full_name}; see the decision log above for the concrete per-PR reason." - failures=$((failures + 1)) - fi - fi - - # Queue hygiene, part 1: classify queued/in-progress runs against a - # bounded PR/default-branch snapshot. The snapshot is intentionally - # cheap and may race with a subsequent head move; every destructive - # cancellation is therefore revalidated against live run/PR/ref state - # immediately before the mutation by the production helper below. - queue_hygiene_ready=true - open_pr_heads_json="{}" - if open_pr_payload_json="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "/repos/${repo_full_name}/pulls?state=open&per_page=100" \ - --paginate \ - | jq -sc '[.[] | .[]]' - )"; then - if ! jq -e ' - all(.[]; - (.head.repo.full_name | type) == "string" and (.head.repo.full_name | length) > 0 and - (.head.ref | type) == "string" and (.head.ref | length) > 0 and - (.head.sha | type) == "string" and (.head.sha | test("^[0-9a-fA-F]{40}$")) - ) - ' <<<"$open_pr_payload_json" >/dev/null; then - echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: an open PR has malformed head repository/ref/SHA metadata. No run will be cancelled from incomplete evidence." - queue_hygiene_ready=false - else - open_pr_heads_json="$( - jq -c ' - reduce .[] as $pr ({}; - . + {(($pr.head.repo.full_name + ":" + $pr.head.ref)): $pr.head.sha} - ) - ' <<<"$open_pr_payload_json" - )" - fi - else - echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: open PR head refs could not be read safely. No run will be cancelled from incomplete evidence." - queue_hygiene_ready=false - fi - if ! current_default_sha="$( - gh api \ - -H "Accept: application/vnd.github+json" \ - "/repos/${repo_full_name}/commits/${default_branch}" \ - --jq '.sha // empty' - )"; then - echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: default-branch HEAD could not be read safely. No run will be cancelled from incomplete evidence." - current_default_sha="" - queue_hygiene_ready=false - elif ! [[ "$current_default_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: default-branch HEAD is malformed. No run will be cancelled from incomplete evidence." - current_default_sha="" - queue_hygiene_ready=false - fi - if ! active_runs_json="$( - for active_status in queued in_progress; do - gh api \ - -H "Accept: application/vnd.github+json" \ - "/repos/${repo_full_name}/actions/runs?status=${active_status}&per_page=100" \ - --paginate - done | jq -sc '[.[] | (.workflow_runs // [])[]]' - )"; then - echo "::warning::Current-HEAD cancellation skipped for ${repo_full_name}: queued/in-progress Actions runs could not be read. Grant the sweep credential Actions read access; no run will be cancelled from incomplete evidence." - active_runs_json="[]" - queue_hygiene_ready=false - fi - superseded_runs_json="[]" - if [ "$queue_hygiene_ready" = "true" ]; then - superseded_runs_json="$( - jq \ - --argjson current_pr_heads "$open_pr_heads_json" \ - --arg default_branch "$default_branch" \ - --arg current_default_sha "$current_default_sha" \ - '[ - .[] - | ((.head_repository.full_name // "") + ":" + (.head_branch // "")) as $head_key - | ($current_pr_heads[$head_key] // null) as $current_pr_head - | select( - if (.event == "pull_request" or .event == "pull_request_target") then - ($current_pr_head == null or .head_sha != $current_pr_head) - elif ( - (.event == "push" or .event == "schedule") and - .head_branch == $default_branch and - $current_default_sha != "" - ) then - .head_sha != $current_default_sha - else - false - end - ) - | { - id, - name, - status, - event, - head_branch, - run_head: .head_sha, - current_head: ( - if (.event == "pull_request" or .event == "pull_request_target") then - $current_pr_head - else - $current_default_sha - end - ), - created_at - } - ]' <<<"$active_runs_json" - )" - fi - superseded_count="$(jq 'length' <<<"$superseded_runs_json")" - if [ "$superseded_count" -gt 0 ]; then - echo "Revalidating ${superseded_count} queued/in-progress run(s) classified as not matching an open PR or default-branch Current HEAD:" - jq -r '.[] | " run \(.id) [\(.name)] status=\(.status) event=\(.event) branch=\(.head_branch) run_head=\(.run_head) classified_head=\(.current_head // "closed-or-no-open-pr")"' <<<"$superseded_runs_json" - if [ "$DRY_RUN" != "true" ]; then - while IFS= read -r run_id; do - scripts/ci/revalidate_queue_cancellation.sh \ - "$repo_full_name" \ - "$run_id" \ - "$default_branch" \ - "$current_default_sha" \ - "$open_pr_heads_json" \ - "superseded" - done < <(jq -r '.[].id' <<<"$superseded_runs_json") - fi - fi - - # Queue hygiene, part 2: retain the legacy age guard only for queued - # runs that are not tied to a currently open PR head. This catches - # orphaned manual/workflow-chain runs without cancelling a valid - # current-head PR check merely because runner capacity was scarce. - # The helper re-checks late PR association/live refs before mutation. - stale_runs_json="[]" - if [ "$queue_hygiene_ready" = "true" ]; then - stale_cutoff="$(date -u -d "${ORG_SWEEP_STALE_QUEUE_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ)" - stale_runs_json="$( - jq \ - --argjson current_pr_heads "$open_pr_heads_json" \ - --argjson superseded "$superseded_runs_json" \ - --arg stale_cutoff "$stale_cutoff" \ - '[ - .[] - | .id as $run_id - | ((.head_repository.full_name // "") + ":" + (.head_branch // "")) as $head_key - | select(.status == "queued") - | select(.created_at < $stale_cutoff) - | select($current_pr_heads[$head_key] == null) - | select(([ $superseded[].id ] | index($run_id)) == null) - | {id, name, event, head_branch, head_sha, created_at} - ]' <<<"$active_runs_json" - )" - fi - stale_count="$(jq 'length' <<<"$stale_runs_json")" - if [ "$stale_count" -gt 0 ]; then - echo "Revalidating ${stale_count} queued run(s) older than ${ORG_SWEEP_STALE_QUEUE_HOURS}h:" - jq -r '.[] | " run \(.id) [\(.name)] on \(.head_branch) queued since \(.created_at)"' <<<"$stale_runs_json" - if [ "$DRY_RUN" != "true" ]; then - while IFS= read -r run_id; do - scripts/ci/revalidate_queue_cancellation.sh \ - "$repo_full_name" \ - "$run_id" \ - "$default_branch" \ - "$current_default_sha" \ - "$open_pr_heads_json" \ - "aged-orphan" - done < <(jq -r '.[].id' <<<"$stale_runs_json") - fi - fi - echo "::endgroup::" - done - - if [ "$unavailable" -gt 0 ]; then - echo "::warning::${unavailable} repository(ies) were skipped as unreachable by the sweep credential (HTTP 403): ${unavailable_repos[*]}. These do not fail the sweep; install the OpenCode app or grant PR_REVIEW_MERGE_TOKEN access to include them." - fi - # Fail-closed guard: a handful of un-enrolled repositories is expected, - # but if MORE than ORG_SWEEP_MAX_UNAVAILABLE repositories become - # unreachable at once the sweep credential itself has regressed and the - # job must fail loudly rather than silently sweeping nothing. - if [ "$unavailable" -gt "$ORG_SWEEP_MAX_UNAVAILABLE" ]; then - echo "::error::Sweep credential could not access ${unavailable} repositories (limit ${ORG_SWEEP_MAX_UNAVAILABLE}); this indicates a credential-scope regression, not a few un-enrolled repositories. Verify PR_REVIEW_MERGE_TOKEN / the OpenCode app installation." - exit 1 - fi - if [ "$failures" -gt 0 ]; then - echo "::error::Organization queue sweep completed with ${failures} repository failure(s); each failure's reason is printed in its repository group above." - exit 1 - fi - echo "Organization queue sweep completed cleanly." diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index a51664be1d..8453895027 100644 --- a/.github/workflows/python-security.yml +++ b/.github/workflows/python-security.yml @@ -46,7 +46,7 @@ jobs: detect-python: name: Detect Python if: github.event.action != 'closed' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: has_python: ${{ steps.detect.outputs.has_python }} has_manifest: ${{ steps.detect.outputs.has_manifest }} @@ -77,7 +77,7 @@ jobs: name: Bandit (Python SAST) needs: detect-python if: github.event.action != 'closed' && needs.detect-python.outputs.has_python == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read security-events: write @@ -185,7 +185,7 @@ jobs: if: always() && hashFiles('bandit-results.sarif') != '' # The explicit gate below still fails on every Medium+ Bandit result. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: bandit-results.sarif category: bandit @@ -204,7 +204,7 @@ jobs: name: pip-audit (Python dependency audit) needs: detect-python if: github.event.action != 'closed' && needs.detect-python.outputs.has_manifest == 'true' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: diff --git a/.github/workflows/r-package-check.yml b/.github/workflows/r-package-check.yml new file mode 100644 index 0000000000..221d66a838 --- /dev/null +++ b/.github/workflows/r-package-check.yml @@ -0,0 +1,155 @@ +# Reusable R CMD check (workflow_call), derived from +# https://github.com/r-lib/actions/tree/v2/examples +# +# Consolidates the near-identical R-CMD-check.yaml files kaefa and nonnest2 +# each carried (r-lib's standard actions/checkout -> setup-pandoc -> +# [setup-tinytex] -> setup-r -> setup-r-dependencies -> check-r-package +# sequence). See docs/adr/0023-r-cmd-check-reusable-workflow-consolidation.md +# and docs/doctoring/r-cmd-check-reusable-workflow-consolidation.md for the +# per-repo field audit behind these inputs. +# +# The `on: push/pull_request` trigger stays in each calling repo's own thin +# workflow file -- a workflow_call target cannot also be the thing GitHub +# triggers directly on push/PR. +# +# Example caller (.github/workflows/R-CMD-check.yaml in a product repo). +# Pin `uses:` to this file's exact commit SHA, not @main: an unpinned mutable +# ref would run an unreviewed central change against every PR check in the +# calling repo (see dependency-review.yml's own header comment and +# docs/doctoring/dependency-review-reusable-workflow-consolidation.md for the +# incident that established this as the required pattern for every reusable +# workflow caller in this org). If the calling repo's branch protection +# requires a status check literally named after the old standalone job, +# converting to `uses:` here will rename the published check to +# " / R-CMD-check" and silently break that required check -- +# check for this before or immediately after merging a caller. +# +# name: R-CMD-check +# on: +# push: +# branches: [main, master] +# pull_request: +# branches: [main, master] +# jobs: +# R-CMD-check: +# uses: ContextualWisdomLab/.github/.github/workflows/r-package-check.yml@ +# with: +# needs_tinytex: true # only if the package builds a PDF vignette +# +name: Reusable R CMD check + +on: + workflow_call: + inputs: + r_matrix: + description: >- + JSON array of {os, r, http-user-agent?} objects for + strategy.matrix.config. Default is a single ubuntu-latest/release + leg; override with a JSON array for a multi-OS/multi-R-version + matrix. + required: false + type: string + default: '[{"os": "ubuntu-latest", "r": "release"}]' + needs_tinytex: + description: "Install r-lib/actions/setup-tinytex before setup-r (needed for a PDF vignette build)." + required: false + type: boolean + default: false + extra_packages: + description: "Value forwarded to setup-r-dependencies's extra-packages input." + required: false + type: string + default: "any::rcmdcheck" + check_args: + description: >- + Value forwarded to check-r-package's args input. Default matches + that action's own upstream default + (c("--no-manual", "--as-cran")); override to change what + rcmdcheck runs (e.g. to skip re-running tests already run by a + bounded pre-check test file). + required: false + type: string + default: 'c("--no-manual", "--as-cran")' + install_package_before_pre_check: + description: >- + Install the current package from source before the optional fixed + testthat pre-check. This is a boolean capability, not caller-authored + shell source. + required: false + type: boolean + default: false + pre_check_test_file: + description: >- + Optional repository-relative testthat file under tests/testthat/ + ending in .R. The value is passed as data through an environment + variable and is never evaluated as shell source. + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + R-CMD-check: + runs-on: ${{ matrix.config.os }} + name: ${{ matrix.config.os }} (${{ matrix.config.r }}) + + strategy: + fail-fast: false + matrix: + config: ${{ fromJSON(inputs.r_matrix) }} + + env: + GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} + R_KEEP_PKG_SOURCE: yes + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: r-lib/actions/setup-pandoc@6f6e5bc62fba3a704f74e7ad7ef7676c5c6a2590 # v2 + + - if: inputs.needs_tinytex + uses: r-lib/actions/setup-tinytex@6f6e5bc62fba3a704f74e7ad7ef7676c5c6a2590 # v2 + + - uses: r-lib/actions/setup-r@6f6e5bc62fba3a704f74e7ad7ef7676c5c6a2590 # v2 + with: + r-version: ${{ matrix.config.r }} + http-user-agent: ${{ matrix.config['http-user-agent'] }} + use-public-rspm: true + + - uses: r-lib/actions/setup-r-dependencies@6f6e5bc62fba3a704f74e7ad7ef7676c5c6a2590 # v2 + with: + extra-packages: ${{ inputs.extra_packages }} + needs: check + + - if: inputs.pre_check_test_file != '' && inputs.install_package_before_pre_check + name: Install package for bounded pre-check + run: Rscript -e 'install.packages(".", repos = NULL, type = "source")' + shell: bash + + - if: inputs.pre_check_test_file != '' + name: Run bounded testthat pre-check + env: + PRE_CHECK_TEST_FILE: ${{ inputs.pre_check_test_file }} + run: | + case "$PRE_CHECK_TEST_FILE" in + tests/testthat/*.R) ;; + *) + echo "::error::pre_check_test_file must be a repository-relative tests/testthat/*.R path" + exit 1 + ;; + esac + if [[ "$PRE_CHECK_TEST_FILE" == *".."* || "$PRE_CHECK_TEST_FILE" == /* || "$PRE_CHECK_TEST_FILE" == *$'\n'* || "$PRE_CHECK_TEST_FILE" == *$'\r'* ]]; then + echo "::error::pre_check_test_file contains a forbidden path/control sequence" + exit 1 + fi + Rscript -e 'testthat::test_file(Sys.getenv("PRE_CHECK_TEST_FILE"))' + shell: bash + + - uses: r-lib/actions/check-r-package@6f6e5bc62fba3a704f74e7ad7ef7676c5c6a2590 # v2 + with: + args: ${{ inputs.check_args }} + build_args: 'c("--no-manual")' + error-on: '"error"' + upload-snapshots: true diff --git a/.github/workflows/sast-semgrep.yml b/.github/workflows/sast-semgrep.yml index 7d78684de2..12b7013da3 100644 --- a/.github/workflows/sast-semgrep.yml +++ b/.github/workflows/sast-semgrep.yml @@ -38,9 +38,72 @@ permissions: contents: read jobs: + changed-scope: + name: Detect changed scope + # The org ruleset IGNORES every `on:` filter (paths, branches, types) when it + # runs this workflow in another repository, and a trigger-level skip would + # leave `.github`'s classic required contexts Pending forever. Both + # mechanisms honour a JOB-level skip, so the doc/image-only decision is made + # here and consumed through `needs`. See + # docs/doctoring/required-workflow-path-filter-boundary.md. + # Fails OPEN: an unreadable, empty, or truncated file list scans everything. + if: github.event.action != 'closed' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.scope.outputs.code }} + deps: ${{ steps.scope.outputs.deps }} + steps: + - name: Classify changed paths + id: scope + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR: ${{ github.event.pull_request.number }} + EXPECTED_FILES: ${{ github.event.pull_request.changed_files }} + shell: bash + run: | + set -uo pipefail + code=true + deps=true + if [ -n "${PR}" ] && [ -n "${EXPECTED_FILES}" ]; then + changed="" + for attempt in 1 2 3; do + if changed="$(gh api --paginate "repos/${REPO}/pulls/${PR}/files?per_page=100" --jq '.[].filename')" && [ -n "$changed" ]; then + break + fi + changed="" + sleep $((attempt * 3)) + done + # GitHub caps /pulls/N/files at 3000 entries; a short list would hide + # source files behind a doc-only verdict, so require an exact count. + if [ -n "$changed" ] && [ "$(printf '%s\n' "$changed" | wc -l | tr -d ' ')" = "${EXPECTED_FILES}" ]; then + code=false + deps=false + while IFS= read -r changed_path; do + case "$changed_path" in + *.md|*.markdown|*.rst|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.bmp|*.ico|LICENSE|LICENSE.txt|COPYING|COPYING.txt|NOTICE|NOTICE.txt|.github/ISSUE_TEMPLATE/*) ;; + *) code=true ;; + esac + case "$changed_path" in + requirements*.txt|*/requirements*.txt|pyproject.toml|*/pyproject.toml|uv.lock|*/uv.lock|pylock.*.toml|*/pylock.*.toml|package.json|*/package.json|package-lock.json|*/package-lock.json|pnpm-lock.yaml|*/pnpm-lock.yaml|yarn.lock|*/yarn.lock|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|go.mod|*/go.mod|go.sum|*/go.sum|pom.xml|*/pom.xml|build.gradle|*/build.gradle|build.gradle.kts|*/build.gradle.kts|DESCRIPTION|*/DESCRIPTION) deps=true ;; + esac + done <<<"$changed" + else + echo "::notice::changed-scope could not read a complete PR file list; scanning everything." + fi + fi + echo "code=${code}" >> "$GITHUB_OUTPUT" + echo "deps=${deps}" >> "$GITHUB_OUTPUT" + echo "changed-scope code=${code} deps=${deps}" + semgrep: name: Semgrep (multi-language SAST) - if: github.event.action != 'closed' + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' runs-on: ubuntu-24.04 permissions: contents: read @@ -124,7 +187,7 @@ jobs: - name: Upload Semgrep SARIF to code scanning if: always() && hashFiles('semgrep-results.sarif') != '' continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: semgrep-results.sarif category: semgrep diff --git a/.github/workflows/sbom-generation.yml b/.github/workflows/sbom-generation.yml index 70b1fe4ac7..588baefe1e 100644 --- a/.github/workflows/sbom-generation.yml +++ b/.github/workflows/sbom-generation.yml @@ -1,9 +1,17 @@ # Central SBOM generation for every ContextualWisdomLab repo. # -# This is a REQUIRED-style org workflow (mirrors security-scan.yml): same -# pull_request trigger conventions, least-privilege permissions, SHA-pinned -# actions. It complements the Security Scan by producing a Software Bill of -# Materials for every repo's dependencies on each PR and release. +# This is a REQUIRED-style org workflow (mirrors security-scan.yml): +# least-privilege permissions, SHA-pinned actions. It complements the +# Security Scan by producing a Software Bill of Materials for every repo's +# dependencies on each push to a protected branch and each release. +# +# NOTE: this used to also run on every PR, but nothing gated on the PR-scoped +# artifact and `dependency-snapshot: true` (below) submits its snapshot to the +# repository dependency graph -- the only feeder of the graph that +# `sbom-inventory-scheduler.yml` (cron: 0 * * * *) reads org-wide. A PR-head +# snapshot briefly pollutes that graph with dependencies from unmerged +# branches, so this now runs only on `push`/`release`, which is also required +# so the hourly inventory keeps a feeder at all. # # What it does per repo: # - Generates BOTH a CycloneDX and an SPDX SBOM with anchore/syft (via the @@ -17,19 +25,17 @@ # the central SBOM inventory aggregator reads back out org-wide. # # NOTE: contents: write is required for release-asset upload and for the -# dependency submission API. Fork PR heads run without write and simply skip -# those side effects; the artifact is still produced. +# dependency submission API. name: SBOM Generation on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] + push: branches: [main, master, develop] release: types: [published] concurrency: - group: sbom-generation-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number || github.event.release.tag_name || github.ref }} + group: sbom-generation-${{ github.repository }}-${{ github.event.release.tag_name || github.ref }} cancel-in-progress: true permissions: @@ -37,7 +43,6 @@ permissions: jobs: generate-sbom: - if: github.event_name != 'pull_request' || github.event.action != 'closed' runs-on: ubuntu-latest permissions: # write is needed for release-asset upload and dependency submission. diff --git a/.github/workflows/scheduled-security-scan.yml b/.github/workflows/scheduled-security-scan.yml index ee9c025289..6b6a90aa36 100644 --- a/.github/workflows/scheduled-security-scan.yml +++ b/.github/workflows/scheduled-security-scan.yml @@ -90,13 +90,13 @@ jobs: with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - name: Perform CodeQL Analysis continue-on-error: true - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: "/language:${{ matrix.language }}-scheduled" @@ -131,7 +131,7 @@ jobs: - name: Upload Trivy SARIF to code scanning if: always() && hashFiles('trivy-results.sarif') != '' continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: trivy-results.sarif category: trivy-fs-scheduled diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index 6e2d7e6982..6222b28b28 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -1,10 +1,28 @@ name: Scorecard analysis on: + # Keep the canonical owner's own default branch covered. push: branches: ["main"] schedule: - cron: "30 1 * * 6" + # Product repositories retain only their repository-specific push/schedule + # trigger and delegate every implementation step to this versioned owner. + workflow_call: + +# Queue two default-branch pushes into one run rather than letting them stack +# unbounded; cancel-in-progress stays false (same tradeoff as strix.yml) so a +# security-scan run for an older main commit is never discarded mid-flight -- +# it still finishes and uploads that commit's SARIF evidence, it is just no +# longer allowed to run alongside a newer queued push for the same branch. +# (This deliberately does NOT scope by exact SHA: a ref-scoped group with +# cancel-in-progress: false is what bounds runaway concurrent Scorecard scans +# across a burst of pushes -- SHA-scoping would give every distinct commit its +# own group, restoring unlimited-parallel-scans, the exact resource-consumption +# problem this group exists to prevent. See #1768.) +concurrency: + group: scorecard-analysis-${{ github.ref }} + cancel-in-progress: false permissions: read-all @@ -65,6 +83,6 @@ jobs: # Scorecard posture is preserved in its SARIF-generation log; an # installation upload quota outage must not fail the default branch. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif diff --git a/.github/workflows/scorecard-pr.yml b/.github/workflows/scorecard-pr.yml deleted file mode 100644 index aea980f6d1..0000000000 --- a/.github/workflows/scorecard-pr.yml +++ /dev/null @@ -1,98 +0,0 @@ -# Runs a supplemental OpenSSF Scorecard analysis on every PR and preserves its -# filtered SARIF as an artifact. The central Security Scan workflow owns the -# PR code-scanning upload so this workflow does not duplicate installation API -# calls or fail a clean PR when GitHub's upload quota is spent. -# -# NOTE: Scorecard reports repository-posture findings (branch protection, token -# permissions, dependency pinning, ...) that are unrelated to the PR diff. The -# central Security Scan job therefore treats Scorecard as soft visibility and -# delegates PR-only SAST/vulnerability posture findings to the dedicated -# CodeQL, OSV, Trivy, and dependency-review hard gates. -name: Scorecard PR - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] - branches: [main, master, develop] - -concurrency: - group: >- - scorecard-pr-${{ - github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name || github.repository }}-${{ - github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - analysis: - name: Scorecard - if: github.event.action != 'closed' - runs-on: ubuntu-24.04 - permissions: - contents: read - actions: read - steps: - - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - name: Run analysis - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - # publish_results is only valid on the default branch; PR runs upload - # SARIF to code scanning without publishing to the public OpenSSF API. - publish_results: false - - - name: Filter delegated PR-only Scorecard SARIF findings - run: | - python3 <<'PY' - import json - import pathlib - - PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"} - PR_GOVERNANCE_RULE_IDS = {"FuzzingID"} - PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS - - sarif_path = pathlib.Path("results.sarif") - sarif = json.loads(sarif_path.read_text(encoding="utf-8")) - hard_gate_delegated = 0 - governance_delegated = 0 - for run in sarif.get("runs", []): - kept = [] - for result in run.get("results", []): - rule_id = result.get("ruleId") - if rule_id in PR_DELEGATED_RULE_IDS: - if rule_id in PR_HARD_GATE_RULE_IDS: - hard_gate_delegated += 1 - if rule_id in PR_GOVERNANCE_RULE_IDS: - governance_delegated += 1 - continue - kept.append(result) - run["results"] = kept - filtered_path = sarif_path.with_name(f"{sarif_path.name}.filtered") - filtered_path.write_text(json.dumps(sarif, indent=2), encoding="utf-8") - filtered_path.replace(sarif_path) - print( - "Delegated " - f"{hard_gate_delegated} PR-only Scorecard SAST/vulnerability finding(s) to " - "CodeQL, OSV, Trivy, and dependency-review hard gates." - ) - print( - "Delegated " - f"{governance_delegated} PR-only Scorecard fuzzing posture finding(s) " - "to default-branch governance tracking." - ) - PY - - - name: Preserve Scorecard PR SARIF evidence - if: always() && hashFiles('results.sarif') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: scorecard-pr-sarif-${{ github.run_id }}-${{ github.run_attempt }} - path: results.sarif - retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index dc529c0ab4..948ae583d4 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -7,10 +7,10 @@ # # gitleaks secret scanning -> HARD gate by job result + SARIF (category "gitleaks") # -# Coverage split (mirrors the removed local behaviour): -# - pull_request : scan only the PR's new commits (base..head) — fast, diff-scoped -# - schedule/push: scan the current protected branch history — catches secrets -# committed earlier without importing unrelated fetched remote branch refs. +# PR scanning now belongs to security-scan.yml so one required bundle owns PR +# security admission. This workflow retains the protected-branch backstops: +# schedule/push scan the current protected branch history, while an explicit +# repository_dispatch remains available for operator-requested evidence. # # Tool license: gitleaks core is MIT. We download the pinned release BINARY # (checksum-verified) rather than gitleaks-action so no org license key is @@ -18,9 +18,6 @@ name: Secret Scan on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review, closed] - branches: [main, master, develop] push: branches: [main, master, develop] schedule: @@ -29,7 +26,7 @@ on: types: [secret-scan] concurrency: - group: secret-scan-${{ github.event.pull_request.base.repo.full_name || github.repository }}-${{ github.event.pull_request.number || github.ref }} + group: secret-scan-${{ github.repository }}-${{ github.ref }} cancel-in-progress: true permissions: @@ -38,7 +35,6 @@ permissions: jobs: gitleaks: name: gitleaks (secret scan) - if: github.event.action != 'closed' runs-on: ubuntu-24.04 permissions: contents: read @@ -52,7 +48,7 @@ jobs: uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 with: egress-policy: audit - - name: Checkout (full history for schedule/push, base+head for PR) + - name: Checkout protected branch history uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -69,9 +65,6 @@ jobs: - name: Run gitleaks id: gitleaks env: - IS_PR: ${{ github.event_name == 'pull_request' }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} CURRENT_SHA: ${{ github.sha }} run: | set +e @@ -79,17 +72,11 @@ jobs: if [ -f .gitleaks.toml ]; then config_args=(--config .gitleaks.toml) fi - if [ "${IS_PR}" = "true" ]; then - # Diff-scoped: only the commits this PR introduces. - log_opts="${BASE_SHA}..${HEAD_SHA}" - echo "::notice::gitleaks scanning pull request commit range ${log_opts}." - else - # Full history reachable from the protected-branch HEAD only. A full - # checkout may contain unrelated remote branch refs; scanning all of - # them reopens stale non-main fixture findings on the main analysis. - log_opts="${CURRENT_SHA}" - echo "::notice::gitleaks scanning protected branch history reachable from ${log_opts}; unrelated remote refs are excluded." - fi + # Full history reachable from the protected-branch HEAD only. A full + # checkout may contain unrelated remote branch refs; scanning all of + # them reopens stale non-main fixture findings on the main analysis. + log_opts="${CURRENT_SHA}" + echo "::notice::gitleaks scanning protected branch history reachable from ${log_opts}; unrelated remote refs are excluded." ./gitleaks git . \ "${config_args[@]}" \ --log-opts="${log_opts}" \ @@ -124,7 +111,7 @@ jobs: - name: Upload gitleaks SARIF to code scanning if: always() && hashFiles('gitleaks-results.upload.sarif') != '' continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: gitleaks-results.upload.sarif category: gitleaks diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 860d861544..500e22b4ab 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -7,8 +7,13 @@ # osv-scan HARD diff-scoped — fails on NEW vulns the PR introduces # dependency-review HARD diff-scoped — fails on vulnerable/denied deps the PR adds # trivy-fs HARD repo-wide — fails on FIXABLE MEDIUM/HIGH/CRITICAL findings +# gitleaks HARD commit-range — blocks secrets in ContextualWisdomLab/.github PRs # scorecard SOFT repo posture — uploaded for visibility, never blocks # +# This is the sole organization-required owner for OSV and Scorecard PR work. +# The standalone workflows remain local to this repository because its classic +# branch protection still requires their historical check contexts. +# # Gating is by the JOB result (a failed job fails this required workflow -> # merge blocked), NOT by the code_scanning ruleset rule. The code_scanning rule # stays CodeQL-only on purpose: requiring multiple code-scanning TOOLS there is @@ -25,6 +30,13 @@ # MEDIUM/HIGH/CRITICAL finding blocks every PR in that repo until it is fixed. # Trivy itself exits 0 so SARIF is always available; the following parser prints # exact findings and then fails the job. +# +# NOTE on the changed-scope gate: each job below now runs only when the +# `changed-scope` job's diff-scoped output says it is in scope (`code` for +# trivy-fs/scorecard, `deps` for osv-scan/dependency-review). A doc/image-only +# PR skips every one of these jobs, and `scheduled-security-scan.yml` (push + +# default-branch schedule) and `scorecard-analysis.yml` (push + weekly cron) +# remain the full repo-wide backstops that make those skips safe. name: Security Scan on: @@ -49,9 +61,72 @@ permissions: contents: read jobs: - osv-scan: + changed-scope: + name: Detect changed scope + # The org ruleset IGNORES every `on:` filter (paths, branches, types) when it + # runs this workflow in another repository, and a trigger-level skip would + # leave `.github`'s classic required contexts Pending forever. Both + # mechanisms honour a JOB-level skip, so the doc/image-only decision is made + # here and consumed through `needs`. See + # docs/doctoring/required-workflow-path-filter-boundary.md. + # Fails OPEN: an unreadable, empty, or truncated file list scans everything. if: github.event.action != 'closed' runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.scope.outputs.code }} + deps: ${{ steps.scope.outputs.deps }} + steps: + - name: Classify changed paths + id: scope + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR: ${{ github.event.pull_request.number }} + EXPECTED_FILES: ${{ github.event.pull_request.changed_files }} + shell: bash + run: | + set -uo pipefail + code=true + deps=true + if [ -n "${PR}" ] && [ -n "${EXPECTED_FILES}" ]; then + changed="" + for attempt in 1 2 3; do + if changed="$(gh api --paginate "repos/${REPO}/pulls/${PR}/files?per_page=100" --jq '.[].filename')" && [ -n "$changed" ]; then + break + fi + changed="" + sleep $((attempt * 3)) + done + # GitHub caps /pulls/N/files at 3000 entries; a short list would hide + # source files behind a doc-only verdict, so require an exact count. + if [ -n "$changed" ] && [ "$(printf '%s\n' "$changed" | wc -l | tr -d ' ')" = "${EXPECTED_FILES}" ]; then + code=false + deps=false + while IFS= read -r changed_path; do + case "$changed_path" in + *.md|*.markdown|*.rst|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.bmp|*.ico|LICENSE|LICENSE.txt|COPYING|COPYING.txt|NOTICE|NOTICE.txt|.github/ISSUE_TEMPLATE/*) ;; + *) code=true ;; + esac + case "$changed_path" in + requirements*.txt|*/requirements*.txt|pyproject.toml|*/pyproject.toml|uv.lock|*/uv.lock|pylock.*.toml|*/pylock.*.toml|package.json|*/package.json|package-lock.json|*/package-lock.json|pnpm-lock.yaml|*/pnpm-lock.yaml|yarn.lock|*/yarn.lock|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|go.mod|*/go.mod|go.sum|*/go.sum|pom.xml|*/pom.xml|build.gradle|*/build.gradle|build.gradle.kts|*/build.gradle.kts|DESCRIPTION|*/DESCRIPTION) deps=true ;; + esac + done <<<"$changed" + else + echo "::notice::changed-scope could not read a complete PR file list; scanning everything." + fi + fi + echo "code=${code}" >> "$GITHUB_OUTPUT" + echo "deps=${deps}" >> "$GITHUB_OUTPUT" + echo "changed-scope code=${code} deps=${deps}" + + osv-scan: + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.deps == 'true' + runs-on: ubuntu-24.04 timeout-minutes: 25 permissions: actions: read @@ -85,7 +160,7 @@ jobs: id: osv_base continue-on-error: true timeout-minutes: 8 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -103,7 +178,7 @@ jobs: if: steps.osv_base.outcome == 'failure' continue-on-error: true timeout-minutes: 4 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -136,7 +211,7 @@ jobs: id: osv_head continue-on-error: true timeout-minutes: 8 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -154,7 +229,7 @@ jobs: if: steps.osv_head.outcome == 'failure' continue-on-error: true timeout-minutes: 4 - uses: google/osv-scanner-action/osv-scanner-action@a82132c0bd6c7261ffcb78e754c46c70ab57ad9a # v2.3.8 + uses: google/osv-scanner-action/osv-scanner-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.5.1-6-g8e5cf47 with: scan-args: | --format=json @@ -208,7 +283,7 @@ jobs: if len(findings) > 50: print(f"... {len(findings) - 50} additional {label} OSV finding(s) omitted from the log summary.") - name: Report PR-introduced OSV findings - uses: google/osv-scanner-action/osv-reporter-action@8dc09193bb540e09b23da07ad7e30bd33bf87018 # v2.3.8 + uses: google/osv-scanner-action/osv-reporter-action@8e5cf47b818121e8b405931c82126c2630b0b20d # v2.3.8 with: scan-args: | --output=results.sarif @@ -245,7 +320,7 @@ jobs: # The reporter above is the vulnerability gate. Preserve an upload # quota failure in this step's log without reclassifying it as a CVE. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif # results.sarif is produced after checkout of the pull request head. @@ -271,7 +346,8 @@ jobs: retention-days: 5 dependency-review: - if: github.event.action != 'closed' + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.deps == 'true' runs-on: ubuntu-24.04 permissions: contents: read @@ -348,8 +424,104 @@ jobs: fail-on-severity: moderate comment-summary-in-pr: never + # Keep the existing central-repository Gitleaks PR gate inside the required + # security bundle. It deliberately does not depend on changed-scope: secrets + # in Markdown or other document-only changes must still fail the PR. The + # repository condition preserves the standalone workflow's previous scope; + # push, schedule, and manual backstops remain in secret-scan.yml. + gitleaks: + name: gitleaks (secret scan) + if: github.event.action != 'closed' && github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + actions: read + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + - name: Checkout PR commit range + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 + - name: Install gitleaks (pinned, checksum-verified) + run: | + set -euo pipefail + url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL "$url" -o gitleaks.tar.gz + echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + ./gitleaks version + - name: Run gitleaks on PR commit range + id: gitleaks + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set +e + config_args=() + if [ -f .gitleaks.toml ]; then + config_args=(--config .gitleaks.toml) + fi + log_opts="${BASE_SHA}..${HEAD_SHA}" + echo "::notice::gitleaks scanning pull request commit range ${log_opts}." + ./gitleaks git . \ + "${config_args[@]}" \ + --log-opts="${log_opts}" \ + --redact \ + --report-format sarif \ + --report-path gitleaks-results.sarif \ + --exit-code 2 + echo "rc=$?" >> "$GITHUB_OUTPUT" + set -e + - name: Summarize redacted gitleaks findings + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + set -euo pipefail + count="$(jq '[.runs[].results[]?] | length' gitleaks-results.sarif)" + if [ "$count" = "0" ]; then + echo "::notice::gitleaks completed with no findings." + exit 0 + fi + echo "::error::gitleaks reported ${count} redacted finding(s). Rule, path, and line summary follows; secret values are not printed." + jq -r ' + .runs[].results[]? + | "- rule: `" + (.ruleId // "unknown") + "`" + + ", path: `" + (.locations[0].physicalLocation.artifactLocation.uri // "unknown") + "`" + + ", line: `" + ((.locations[0].physicalLocation.region.startLine // "unknown") | tostring) + "`" + ' gitleaks-results.sarif | sort | uniq -c + - name: Filter test-classified Gitleaks SARIF results + if: always() && hashFiles('gitleaks-results.sarif') != '' + run: | + python3 scripts/ci/filter_gitleaks_sarif.py \ + gitleaks-results.sarif \ + gitleaks-results.upload.sarif + - name: Upload gitleaks SARIF to code scanning + if: always() && hashFiles('gitleaks-results.upload.sarif') != '' + continue-on-error: true + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + sarif_file: gitleaks-results.upload.sarif + category: gitleaks + ref: refs/pull/${{ github.event.pull_request.number }}/head + sha: ${{ github.event.pull_request.head.sha }} + wait-for-processing: false + - name: Enforce secret-scan gate + if: steps.gitleaks.outputs.rc != '0' + run: | + echo "::error::gitleaks detected potential secrets (exit ${{ steps.gitleaks.outputs.rc }}). Rotate any exposed credential and scrub history." + exit 1 + trivy-fs: - if: github.event.action != 'closed' + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' runs-on: ubuntu-24.04 permissions: contents: read @@ -442,7 +614,7 @@ jobs: if: always() && hashFiles('trivy-results.sarif') != '' # The parser above fails on every fixable Medium+ finding independently. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: trivy-results.sarif category: trivy-fs @@ -455,7 +627,8 @@ jobs: echo "::warning::Trivy SARIF upload to code scanning failed after the filesystem scan. The Trivy finding log above remains the hard gate, so upload rate limits cannot hide CRITICAL/HIGH/MEDIUM findings." scorecard: - if: github.event.action != 'closed' + needs: changed-scope + if: github.event.action != 'closed' && needs.changed-scope.outputs.code == 'true' runs-on: ubuntu-24.04 # SOFT: posture findings are unrelated to the PR diff, so never block merge. continue-on-error: true @@ -532,7 +705,7 @@ jobs: id: upload_scorecard_sarif # Scorecard is soft repository-posture evidence; upload quota is external. continue-on-error: true - uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif category: scorecard diff --git a/.github/workflows/strix-changed-path-quality-ci.yml b/.github/workflows/strix-changed-path-quality-ci.yml deleted file mode 100644 index 31924910a3..0000000000 --- a/.github/workflows/strix-changed-path-quality-ci.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Strix Changed Path Quality CI - -on: - pull_request: - branches: [main] - paths: - - ".github/workflows/strix-changed-path-quality-ci.yml" - - ".github/workflows/strix.yml" - - "CHANGELOG.md" - - "docs/doctoring/strix-legal-git-paths.md" - - "docs/doctoring/strix-model-behavior-error.md" - - "docs/doctoring/strix-quality-timeout-fixtures.md" - - "scripts/ci/strix_quick_gate.sh" - - "scripts/ci/test_strix_quick_gate.sh" - - "tests/test_strix_changed_path_policy.py" - - "tests/test_strix_model_behavior_error.py" - - "tests/test_strix_nvidia_nim_not_found_fallback.py" - - "tests/test_strix_workflow_dependency_hashes.py" - - "tests/test_strix_quality_timeout_fixture_budget.py" - -permissions: - contents: read - -concurrency: - group: strix-changed-path-quality-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - exact-head-path-policy: - if: github.event_name != 'pull_request' || github.event.action != 'closed' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Checkout exact source revision - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: "3.14" - - - name: Install exact hash-verified test runner dependencies - env: - PIP_DISABLE_PIP_VERSION_CHECK: "1" - PIP_NO_INPUT: "1" - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - cat >"${RUNNER_TEMP}/strix-quality-requirements.txt" <<'EOF' - coverage==7.15.2 --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f - iniconfig==2.1.0 --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 - packaging==26.2 --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e - pluggy==1.6.0 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - pygments==2.20.0 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 - pytest==9.1.1 --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c - EOF - python -m pip install \ - --only-binary=:all: \ - --require-hashes \ - -r "${RUNNER_TEMP}/strix-quality-requirements.txt" - - - name: Verify exact-head path policy and syntax - env: - STRIX_TEST_PROCESS_TIMEOUT_SECONDS: "3" - STRIX_TEST_FAKE_SLEEP_SECONDS: "5" - shell: bash --noprofile --norc -e -o pipefail {0} - run: | - test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" - python -m coverage run -m pytest tests -q - bash scripts/ci/test_strix_quick_gate.sh - python -m compileall -q tests/test_strix_changed_path_policy.py tests/test_strix_model_behavior_error.py tests/test_strix_nvidia_nim_not_found_fallback.py tests/test_strix_workflow_dependency_hashes.py tests/test_strix_quality_timeout_fixture_budget.py - bash -n scripts/ci/strix_quick_gate.sh - git diff --exit-code diff --git a/.github/workflows/strix.yml b/.github/workflows/strix.yml index d7e3f5b05a..58ed3dab8d 100644 --- a/.github/workflows/strix.yml +++ b/.github/workflows/strix.yml @@ -17,6 +17,9 @@ on: # no build scripts). A diff touching even one non-listed file still scans. # The weekly full-tree schedule below re-scans protected branches with no # path filter, backstopping every path. + # This filter is only evaluated for natively-triggered runs. Repositories + # covered by org ruleset 18156473 have every 'on:' filter ignored; the + # job-level gate below is what skips them. paths-ignore: - '**/*.md' - '**/*.markdown' @@ -33,11 +36,13 @@ on: - 'COPYING' - '.github/ISSUE_TEMPLATE/**' pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, closed] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed] # Same conservative doc/image-only skip for PR scans. GitHub evaluates these - # 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 + # path filters only for natively-triggered runs -- i.e. in the three + # repositories ruleset 18156473 excludes (.github, noema, + # IRT-bibliography-set). In every other repository the ruleset ignores + # them, so the same doc/image-only decision is enforced by the + # changed-scope job below. 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 @@ -69,6 +74,17 @@ on: repository_dispatch: types: [strix-scan] +concurrency: + # Workflow-level admission is required: job-level groups are never evaluated + # while the whole run is queued behind the organization job ceiling. + group: >- + strix-security-scan-${{ + github.event.pull_request.base.repo.full_name || + github.event.client_payload.target_repository || github.repository }}-${{ + github.event.pull_request.number || + github.event.client_payload.pr_number || github.run_id }} + cancel-in-progress: true + # Scorecard Token-Permissions (alert #43): keep the workflow-level token # read-only and scope same-repo status publication to the Strix scan job. permissions: @@ -77,15 +93,153 @@ permissions: models: read jobs: + changed-scope: + name: Detect changed scope + # The org ruleset IGNORES every `on:` filter (paths, branches, types) when it + # runs this workflow in another repository, and a trigger-level skip would + # leave `.github`'s classic required contexts Pending forever. Both + # mechanisms honour a JOB-level skip, so the doc/image-only decision is made + # here and consumed through `needs`. See + # docs/doctoring/required-workflow-path-filter-boundary.md. + # Fails OPEN: an unreadable, empty, or truncated file list scans everything. + if: github.event_name != 'pull_request_target' || (github.event.action != 'closed' && github.event.action != 'converted_to_draft') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + code: ${{ steps.scope.outputs.code }} + deps: ${{ steps.scope.outputs.deps }} + steps: + - name: Classify changed paths + id: scope + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR: ${{ github.event.pull_request.number }} + EXPECTED_FILES: ${{ github.event.pull_request.changed_files }} + shell: bash + run: | + set -uo pipefail + code=true + deps=true + if [ -n "${PR}" ] && [ -n "${EXPECTED_FILES}" ]; then + changed="" + for attempt in 1 2 3; do + if changed="$(gh api --paginate "repos/${REPO}/pulls/${PR}/files?per_page=100" --jq '.[].filename')" && [ -n "$changed" ]; then + break + fi + changed="" + sleep $((attempt * 3)) + done + # GitHub caps /pulls/N/files at 3000 entries; a short list would hide + # source files behind a doc-only verdict, so require an exact count. + if [ -n "$changed" ] && [ "$(printf '%s\n' "$changed" | wc -l | tr -d ' ')" = "${EXPECTED_FILES}" ]; then + code=false + deps=false + while IFS= read -r changed_path; do + case "$changed_path" in + *.md|*.markdown|*.rst|*.png|*.jpg|*.jpeg|*.gif|*.webp|*.bmp|*.ico|LICENSE|LICENSE.txt|COPYING|COPYING.txt|NOTICE|NOTICE.txt|.github/ISSUE_TEMPLATE/*) ;; + *) code=true ;; + esac + case "$changed_path" in + requirements*.txt|*/requirements*.txt|pyproject.toml|*/pyproject.toml|uv.lock|*/uv.lock|pylock.*.toml|*/pylock.*.toml|package.json|*/package.json|package-lock.json|*/package-lock.json|pnpm-lock.yaml|*/pnpm-lock.yaml|yarn.lock|*/yarn.lock|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock|go.mod|*/go.mod|go.sum|*/go.sum|pom.xml|*/pom.xml|build.gradle|*/build.gradle|build.gradle.kts|*/build.gradle.kts|DESCRIPTION|*/DESCRIPTION) deps=true ;; + esac + done <<<"$changed" + else + echo "::notice::changed-scope could not read a complete PR file list; scanning everything." + fi + fi + echo "code=${code}" >> "$GITHUB_OUTPUT" + echo "deps=${deps}" >> "$GITHUB_OUTPUT" + echo "changed-scope code=${code} deps=${deps}" + + admit-current-head: + name: Admit current pull request head + if: >- + github.event_name != 'pull_request_target' || + (github.event.action != 'closed' && github.event.action != 'converted_to_draft') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + admitted: ${{ steps.admission.outputs.admitted }} + target_repository: ${{ steps.admission.outputs.target_repository }} + pr_number: ${{ steps.admission.outputs.pr_number }} + steps: + - name: Verify event metadata against the live pull request + id: admission + env: + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + EVENT_NAME: ${{ github.event_name }} + TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository }} + TARGET_PR_NUMBER: ${{ github.event.client_payload.pr_number || github.event.pull_request.number }} + EXPECTED_BASE_REF: ${{ github.event.client_payload.pr_base_ref || github.event.pull_request.base.ref }} + EXPECTED_BASE_SHA: ${{ github.event.client_payload.pr_base_sha || github.event.pull_request.base.sha }} + EXPECTED_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || github.event.client_payload.target_repository }} + EXPECTED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || github.event.pull_request.head.sha }} + shell: bash + run: | + set -euo pipefail + printf 'admitted=false\n' >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" != "pull_request_target" ] && [ "$EVENT_NAME" != "repository_dispatch" ]; then + { + echo "admitted=true" + echo "target_repository=${TARGET_REPOSITORY}" + echo "pr_number=${GITHUB_RUN_ID}" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ ! "$TARGET_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || + [[ ! "$TARGET_PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + [[ ! "$EXPECTED_BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || + [[ ! "$EXPECTED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::Strix event metadata is incomplete or malformed." + exit 1 + fi + pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${TARGET_PR_NUMBER}")" + live_tuple="$(jq -r '[.state // "", .base.repo.full_name // "", .base.ref // "", .base.sha // "", .head.repo.full_name // "", .head.sha // ""] | @tsv' <<<"$pull_request_json")" + expected_tuple="$(printf 'open\t%s\t%s\t%s\t%s\t%s' "$TARGET_REPOSITORY" "$EXPECTED_BASE_REF" "$EXPECTED_BASE_SHA" "$EXPECTED_HEAD_REPOSITORY" "$EXPECTED_HEAD_SHA")" + if [ "$live_tuple" != "$expected_tuple" ]; then + echo "::notice::Strix event does not match the live pull request head; skipping stale evidence." + exit 0 + fi + { + echo "admitted=true" + echo "target_repository=${TARGET_REPOSITORY}" + echo "pr_number=${TARGET_PR_NUMBER}" + } >> "$GITHUB_OUTPUT" + cancel-superseded-pr-runs: - if: github.event_name == 'pull_request_target' && (github.event.action == 'synchronize' || github.event.action == 'closed') + if: >- + github.event_name == 'pull_request_target' && + (github.event.action == 'synchronize' || github.event.action == 'converted_to_draft' || github.event.action == 'closed') + # Idempotent per PR: a fresh sweep re-verifies live state (live_target_matches + # below) before selecting or cancelling anything, so it fully subsumes + # whatever an older, not-yet-run instance would have done. cancel-in-progress + # true is the right shape here (the merge scheduler's integrated exact-head + # coalescer instead uses its own admission-order queueing, since each instance + # carries a DIFFERENT specific expected-head only it can act on): it caps + # this job to one running + one queued per PR instead of letting a push + # burst pile up N independent, mutually-non-deduped sweeps that each cost a + # full admission slot under the shared 60-job ceiling. Matches + # codeql-pr.yml's established group-key style (PR-number scoped). + concurrency: + group: >- + cancel-superseded-pr-runs-${{ + github.event.pull_request.base.repo.full_name || github.repository }}-${{ + github.event.pull_request.number || github.run_id }} + cancel-in-progress: true runs-on: ubuntu-24.04 # Bound this gh-api-only cleanup job so a stuck call (rate limit, hung # `gh api --paginate`) cannot silently occupy a runner for GitHub's # 360-minute platform default -- exactly the window when a busy PR is # producing the superseded runs this job exists to retire. Matches - # current-head-run-coalescer.yml's timeout-minutes: 10 for the same - # run-cleanup shape (checkout-free, gh-api-only, no provider inference). + # the merge scheduler's bounded run-cleanup shape (gh-api-only, no provider + # inference). timeout-minutes: 10 # Prefer the established scheduler credential, but let the close event use # its job-scoped token so abandoned scans are cancelled even when that @@ -102,21 +256,26 @@ jobs: 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 + - name: Cancel queued and running scans for superseded or inactive pull requests shell: bash run: | set -euo pipefail live_target_matches() { - local live_pr_json live_action + local live_pr_json live_state live_draft live_head 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" ]; } + live_state="$(jq -r '.state // ""' <<<"$live_pr_json")" + live_draft="$(jq -r '.draft // false' <<<"$live_pr_json")" + live_head="$(jq -r '.head.sha // ""' <<<"$live_pr_json")" + [ "$live_head" = "$TARGET_PR_HEAD_SHA" ] && { + { [ "$PR_ACTION" = "closed" ] && [ "$live_state" = "closed" ]; } || + { [ "$PR_ACTION" = "converted_to_draft" ] && [ "$live_state" = "open" ] && [ "$live_draft" = "true" ]; } || + { [ "$PR_ACTION" = "synchronize" ] && [ "$live_state" = "open" ]; } + } } cancel_runs() { @@ -152,6 +311,7 @@ jobs: )) as $metadata_has_head | select( $action == "closed" + or $action == "converted_to_draft" or (($title_matches or $metadata_has_head) and (($title_is_current or $metadata_is_current) | not)) ) | .id @@ -180,17 +340,8 @@ jobs: 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. - group: >- - strix-${{ - (github.event_name == 'pull_request_target' || github.event_name == 'repository_dispatch') && - format('{0}-{1}', github.event_name, github.event.client_payload.target_repository || github.event.pull_request.base.repo.full_name || github.repository) || - format('{0}-{1}-{2}', github.event_name, github.repository, github.ref) - }} - cancel-in-progress: false + needs: [changed-scope, admit-current-head] + if: needs.changed-scope.outputs.code == 'true' && needs.admit-current-head.outputs.admitted == 'true' # Large, actively-growing repositories (e.g. contextual-orchestrator) can # legitimately require well over two hours to scan -- this org's own # standing operating directive accepts that central OpenCode/Strix/Noema @@ -586,9 +737,11 @@ jobs: ;; esac strix_model="$(printf '%s' "$STRIX_MODEL" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - echo "strix_model=$strix_model" >> "$GITHUB_OUTPUT" - echo 'enabled=true' >> "$GITHUB_OUTPUT" - echo 'provider_mode=contextual_orchestrator' >> "$GITHUB_OUTPUT" + { + echo "strix_model=$strix_model" + echo 'enabled=true' + echo 'provider_mode=contextual_orchestrator' + } >> "$GITHUB_OUTPUT" - name: Provision contextual-orchestrator Strix sidecar if: steps.gate.outputs.enabled == 'true' @@ -767,9 +920,6 @@ jobs: # The gateway auto pool is provider-diverse. Strix function tools # must not send a provider-specific reasoning setting to every route. STRIX_REASONING_EFFORT: none - STRIX_LLM_MAX_RETRIES: 1 - STRIX_TRANSIENT_RETRY_PER_MODEL: 2 - STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS: 60 # The gateway owns discovery and provider failover; Strix must not # bypass its ZDR/privacy policy with an external fallback model. STRIX_FALLBACK_MODELS: "" @@ -815,60 +965,16 @@ jobs: # evidence, but remains non-passing because no authoritative complete # vulnerability result exists. # - # A typed provider outage with no reported vulnerability finding is - # retried with linear backoff inside this step so transient - # provider failures do not fail the required check on the first - # attempt. Genuine findings, configuration failures, and unexpected - # exit codes never retry, and all-terminal outcomes remain fail-closed. + # The gateway owns provider discovery, repair, and failover. Invoke + # the trusted gate once so repository-side retries cannot multiply a + # single PR scan into hours of shared-runner occupancy. strix_run_log="$RUNNER_TEMP/strix_gate_console.log" : > "$strix_run_log" strix_terminal_log="$strix_run_log" strix_rc=0 - strix_gate_attempt=1 set +e - while : ; do - strix_attempt_log="$RUNNER_TEMP/strix_gate_console_attempt_${strix_gate_attempt}.log" - : > "$strix_attempt_log" - bash "$TRUSTED_STRIX_GATE" 2>&1 | tee "$strix_attempt_log" - strix_rc="${PIPESTATUS[0]}" - cat "$strix_attempt_log" >> "$strix_run_log" - strix_terminal_log="$strix_attempt_log" - if [ "$strix_rc" -eq 0 ]; then - break - fi - # Only exit-code 1 scan failures can be infrastructure outcomes. - if [ "$strix_rc" -ne 1 ]; then - break - fi - # Scope this attempt's retry decision to the log tail after the - # last pipeline-continuation marker, exactly like the terminal - # classification below: an already-exempted finding before the - # marker must not mask a retryable outage after it. - strix_retry_scope_log="$strix_terminal_log" - if grep -Fq 'allowing pipeline continuation' "$strix_terminal_log"; then - strix_retry_scope_log="$RUNNER_TEMP/strix_gate_console_tail.log" - awk '/allowing pipeline continuation/{buf=""; next} {buf=buf $0 "\n"} END{printf "%s", buf}' \ - "$strix_terminal_log" > "$strix_retry_scope_log" - fi - # A reported vulnerability is authoritative evidence: never retry - # and never risk downgrading it. - if grep -Eiq "$reported_vulnerability_signal" "$strix_retry_scope_log"; then - break - fi - # Retry only recognized provider-outage / model-behavior classes. - if ! grep -Eiq "$backend_unavailable_signal" "$strix_retry_scope_log" \ - && ! grep -Eq "$model_behavior_error_signal" "$strix_retry_scope_log"; then - break - fi - backoff_seconds=$(( ${STRIX_GATE_RETRY_BACKOFF_SECONDS:-90} * strix_gate_attempt )) - if [ "$strix_gate_attempt" -ge 3 ]; then - echo "Provider-unavailable Strix attempt ${strix_gate_attempt} reached the retry limit; failing closed." >&2 - break - fi - echo "Strix provider outage on attempt ${strix_gate_attempt}; retrying after ${backoff_seconds}s backoff." >&2 - sleep "$backoff_seconds" - strix_gate_attempt=$(( strix_gate_attempt + 1 )) - done + bash "$TRUSTED_STRIX_GATE" 2>&1 | tee "$strix_terminal_log" + strix_rc="${PIPESTATUS[0]}" set -e if [ "$strix_rc" -eq 0 ]; then diff --git a/.github/workflows/trusted-uv-materializer-quality-ci.yml b/.github/workflows/trusted-uv-materializer-quality-ci.yml index 8c4e04f7f5..db70ec324c 100644 --- a/.github/workflows/trusted-uv-materializer-quality-ci.yml +++ b/.github/workflows/trusted-uv-materializer-quality-ci.yml @@ -27,7 +27,7 @@ on: - "pyproject.toml" concurrency: - group: trusted-uv-materializer-quality-${{ github.event.pull_request.number || github.ref }} + group: trusted-uv-materializer-quality-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: diff --git a/AGENTS.md b/AGENTS.md index cf8df236be..e955f8b36a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,184 @@ see [`docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md`](docs/adr/0003 false claim of explicit owner direction and records the resulting availability risk as open and unreviewed, not accepted. The materialization contract is also covered by [`docs/doctoring/exact-artifact-sbom-attestation.md`](docs/doctoring/exact-artifact-sbom-attestation.md). + +## Actions queue and protected-merge procedure + +- Use `github-actions-privileged-pr-scan` when a PR scanner can reach secrets, + and use `github-robot-review-gate` plus `babysit-pr` when diagnosing or + monitoring a protected PR. If a named skill is unavailable, preserve its + fail-closed trust boundary and exact-current-head evidence rules manually. +- PR-triggered workflow concurrency must be trigger-aware. Group by workflow, + target repository, and pull request number with `cancel-in-progress: true`; + do not include the head SHA, because that prevents a new head from cancelling + its predecessor. Non-PR triggers need an explicit collision-safe fallback. +- Put concurrency at workflow scope when queued jobs must be coalesced before a + runner is admitted. Job-level concurrency cannot relieve a saturated runner + queue because it is evaluated only after job admission. +- Keep cleanup repository-local and event-driven. Do not restore an + organization-wide queue sweep, polling `sleep`, or another scheduled scan to + compensate for incorrect concurrency. Cancel only runs proven to belong to a + superseded head of the same PR, then verify each accepted cancellation + reaches `completed/cancelled`. +- Classify a run's PR head by event-specific evidence before cancellation. + `pull_request` may use the run's top-level `head_sha`, but + `pull_request_target` records the trusted base there; use its PR association + and immutable run name/event payload instead. A `repository_dispatch` run + also executes on the control-plane branch, so bind it to the validated target + repository, PR number, and target-head SHA from its payload or run name. + Never compare either event's top-level `head_sha` directly with the live PR + head. If a current-head dispatch is cancelled while deduplicating, enqueue + exactly one replacement for that PR and workflow and verify the replacement + carries the same live target head. +- Before every review, retry, push, or merge claim, re-fetch the PR's exact head + SHA, base SHA, review threads, required checks, and ruleset result. A push + invalidates earlier checks and reviews. Never self-approve, dismiss reviews, + force-push, disable a security gate, or use admin bypass for product or + security changes. + +## Verification discipline + +Many agent sessions work this organization concurrently under the same standing +brief. Silence is not evidence: "I have not touched X" describes one session's +history, never the organization's actual state. + +- **Before calling an item "not started" or a dependency "not adopted", check + beyond your own session.** Search organization-wide (`gh search prs --owner + ContextualWisdomLab ""` — note it returns 30 results by default, so + it is a lead, not an exhaustive sweep), check whether a dedicated repository + already owns the responsibility, then clone the target repository and read the + real integration surface: compose files, the module that would consume the + dependency, its docstrings and comments. A PR-title survey cannot see + infrastructure already deployed with no PR trail, nor a deliberate + non-adoption decision recorded only in a code comment. Both failures are + documented in + [`docs/doctoring/egressweave-wardnet-adoption-audit-contextual-orchestrator-20260903.md`](docs/doctoring/egressweave-wardnet-adoption-audit-contextual-orchestrator-20260903.md). +- **A negative capability claim — "library X *cannot* do Y" — needs X's own + source, not its README.** Clone the library and read its policy/configuration + code and its test suite, which often carries the clearest worked example of + the edge case in question. A feature-list summary is not sufficient evidence + for a negative claim, least of all when that claim becomes a "do not adopt" + recommendation other agents will treat as settled. The record above is an + instance: a documented, tested configuration override was missed by reading + only the README. +- **A peer restating a claim is not corroboration of it.** If two sessions both + rely on the same summary, that is one check, not two. Independent + verification means each examines the primary evidence — the code, the API + response, the log — from a different vantage point. +- **Prefer a different model family for adversarial review of your own + conclusions.** Sessions here share a model and tend to share blind spots. A + read-only `codex exec -s read-only -C ""` pass has already + caught a factual error in this very section that same-family review missed. + +## Verifying a "superseded — closing" claim + +`docs/org-required-workflow-rollout.md` allows retiring a PR "only after verified +complete successor carryover of every unique valid delta; redundancy alone is not +a close instruction." Verify that carryover against the tree, not against how +convincing the closing comment reads. These commands narrow it down; none of +them alone proves succession. + +- Read what the branch actually contributes with a **three-dot** diff: + `git diff --stat origin/main...`. Two-dot (`origin/main `) also + reports changes `main` gained that the branch lacks, which on a stale PR reads + as large phantom deletions by the PR. A long-lived branch's title records what + it was opened for, so it is not evidence of current scope either. +- Look for each claimed-inherited piece by content: `git grep -lF "" + origin/main --` (use `-F`; `git grep` treats the pattern as a regex otherwise). + No output means that exact string is absent from `main` — strong evidence the + delta is missing, but not proof, since a successor may have renamed or + restructured the same behaviour. Conversely a match is not proof of inheritance: + the same name can carry different behaviour. +- `git show origin/main:` tells you whether the path exists on `main` + **now**. A non-zero exit does not mean the content never landed — it may have + landed and later been deleted — and success does not mean the successor kept + the predecessor's changes to it. +- Ancestry is the wrong tool here. `git merge-base --is-ancestor main` + answers "was this commit object merged", not "is this content on `main`". This + repository mixes squash merges with real merge commits, so a squash-carried + delta reports false while a later-reverted one still reports true. +- When the delta is provably absent and no successor accounts for it, reopen + (`gh api repos///pulls/ -X PATCH -f state=open`) and comment the + commands and their output. Missing evidence is not the same as disproven + succession: if the check is merely inconclusive, say so and ask, rather than + reopening or letting the closure stand unexamined. + +## Supersession and constant-change review + +- When a large PR is narrowed into successors, verify the **union** of those + successors against the original's full diff — not merely that each successor's + own tests pass. `#1871` was closed in favor of `#1877` plus `#1879`; both + successors were green, but neither carried `#1871`'s coverage/docstring delta, + so the required 100% gate stayed broken on `main` until `#1883` recovered it. + "Each piece works" and "the pieces together still cover the original's scope" + are different questions, and only the second one needs a diff against the + original. +- Use the per-delta commands in "Verifying a 'superseded — closing' claim" above + against **each** successor, then ask the question those commands cannot: does + anything in the original's scope survive in none of them? A split fails + differently from a single bad closure — no individual successor looks wrong. +- A closure or narrowing is not self-verifying, and neither is a note recording + it. Git-level checks show whether the text moved; they do not show whether the + behaviour is restored. Finish by re-running the gate the original PR existed to + fix and confirming it passes on `main` itself from a fresh clone. +- Never endorse a timeout, retry budget, or other numeric constant on a + model-invocation path without first reading + [`docs/product-goal-directive.md`](docs/product-goal-directive.md) section 8, + which states that central OpenCode, Strix, and Noema accept taking more than two + hours per model ("중앙 OpenCode, Strix, Noema는 모델당 두 시간 이상 걸릴 수 있음을 + 수용한다") and that speed is not a core consideration, accuracy is + ("속도는 핵심 고려사항이 아니며 정확성을 우선한다"). `#1889`, `#1890`, and `#1892` + each capped a model step at 900 seconds on real evidence of a multi-hour hang, + and all three were reverted (`#1891`, `#1895`). Compelling hang evidence does not + exempt a change from that contract: runner occupancy is repaired at the + admission/continuation boundary or by an explicit provider terminal signal, never + by converting elapsed inference time into a model-failure verdict. +- Verify a citation before you rely on it, including your own. The first draft of + the bullet above cited a section number that does not exist in that file and + attributed a "timeout defaults to null" sentence to it that appears only in + `#1891`'s PR body — both caught by grepping the file instead of trusting the + summary that introduced them. + +## Test-gate regressions and stale-PR merges + +- A red `tests`, coverage, or `interrogate` gate on your pull request is not proof that your + diff caused it. Full-suite execution on a push to `main` is not guaranteed: the workflows + that run `pytest tests` on push are `paths:`-filtered, so a pairing broken outside their + declared paths reaches `main` with no full-suite run. The breakage then surfaces on the + next pull request whose review dispatch does run the suite, and fails it regardless of + that request's own diff. This procedure covers the suite gates only; a red Semgrep, + CodeQL, Strix, or Scorecard check is a different diagnosis. +- Reproduce a suspect failure on a clean baseline before repairing it. Run + `git worktree add /tmp/baseline --detach`, then `cd /tmp/baseline` + and run `python3 -m pytest tests -q`; that takes roughly four minutes and needs no + virtualenv. You must `cd` into the worktree: over thirty test files read repository files + through working-directory-relative paths such as `Path(".github/workflows/...")`, so + pointing pytest at the baseline directory from your own checkout silently tests your tree + and reports a green baseline that proves nothing. Baseline the pull request's actual base + or merge-base rather than `origin/main` once `main` has moved past it. If the failure + reproduces on the baseline it is pre-existing: repair it as its own pull request and name + the change that introduced it. +- When you change a workflow file or a `scripts/ci/` module, grep the whole `tests/` tree + for every literal you touched — event-type strings, cron expressions, environment-variable + names, tuple members, pinned digests — not only the obviously named sibling test. A change + can satisfy one oracle and still leave a second, independent one stale. +- Read a stale pull request's own changes with a three-dot diff — + `git diff ...` — or with `gh pr diff`, which is already three-dot. A two-dot + `git diff ` renders everything the base gained since the fork point as though + this branch deleted it, so an untouched branch reads as a mass revert. +- Content-hash pins exist under `tests/`; find them before editing a workflow. Run + `grep -rn 'hash-object' tests/` — today that is the `git hash-object` pin of + `.github/workflows/opencode-review-dispatch.yml`. Any byte change to a pinned file makes + its constant stale and fails a required gate for every open pull request, reverts included, + because a revert restores the original bytes while the pin stays on the reverted value. + Recompute only with `git hash-object `, and only for a constant you have confirmed is + a blob pin. Nearly every other forty-hex literal under `tests/` is something else — a + pinned action SHA, a vendored-revision pin, a synthetic fixture head, or an assertion that + a SHA appears in a document — and pointing `hash-object` at any of those produces a wrong + value that breaks what it replaces. A second contract re-derives the dispatch pin by + regular expression from the first, so keep the assignment on one line and correct it in one + place. +- Production code under `scripts/ci/` branches on `GITHUB_ACTIONS`, and pytest inherits that + variable in CI, so a failure class exists that cannot reproduce locally. Before calling a + scheduler change clean, run the affected tests both ways, including + `GITHUB_ACTIONS=true python3 -m pytest `. diff --git a/CHANGELOG.d/20260903-agent-review-runtime-quality-consolidation.md b/CHANGELOG.d/20260903-agent-review-runtime-quality-consolidation.md new file mode 100644 index 0000000000..5e65dabf9b --- /dev/null +++ b/CHANGELOG.d/20260903-agent-review-runtime-quality-consolidation.md @@ -0,0 +1,9 @@ +## Changed + +- Noema token-lifetime, OpenCode Rust coverage, Strix changed-path 품질 검증을 + `Agent Review Runtime Quality CI`의 단일 exact-head runner로 통합했습니다. +- PR concurrency를 + `agent-review-runtime-quality-{repository}-{PR번호}`와 + `cancel-in-progress: true`로 고정해 같은 PR의 구형 품질 실행만 취소합니다. +- 중복 checkout·Python setup·dependency boot와 Strix 전 저장소 test 실행을 제거하고, + 변경 파일에 맞는 영구 계약만 선택 실행합니다. diff --git a/CHANGELOG.d/20260903-exact-artifact-quality-runner-consolidation.md b/CHANGELOG.d/20260903-exact-artifact-quality-runner-consolidation.md new file mode 100644 index 0000000000..f53d408990 --- /dev/null +++ b/CHANGELOG.d/20260903-exact-artifact-quality-runner-consolidation.md @@ -0,0 +1,9 @@ +## Changed + +- Exact Artifact SBOM Attestation 품질 검증의 Python 3.10 compile job과 Python 3.14 + coverage job을 한 exact-head runner로 통합했습니다. +- runner 부팅·harden-runner·checkout을 실행당 2회에서 1회로 줄이고 최소 Python + 호환성, branch coverage 100%, docstring 100% 계약은 보존했습니다. +- PR concurrency를 + `exact-artifact-sbom-attestation-quality-{repository}-{PR번호}`와 + `cancel-in-progress: true`로 고정했습니다. diff --git a/CHANGELOG.d/20260903-reusable-default-branch-scorecard.md b/CHANGELOG.d/20260903-reusable-default-branch-scorecard.md new file mode 100644 index 0000000000..383490d779 --- /dev/null +++ b/CHANGELOG.d/20260903-reusable-default-branch-scorecard.md @@ -0,0 +1,14 @@ +## Reusable default-branch Scorecard owner + +- Centralize OSSF Scorecard execution, SARIF filtering, and code-scanning upload in + `.github/workflows/scorecard-analysis.yml` while preserving the canonical owner's + default-branch push and weekly schedule and exposing a `workflow_call` contract. +- Keep the ref-scoped, `cancel-in-progress: false` concurrency group `.github#1768` + already established (queue rather than cancel a burst of same-ref pushes, so an + in-flight scan's SARIF evidence for its own commit is never discarded). +- Keep consumer rollout incomplete until each repository replaces copied logic with + a thin caller pinned to the central merge commit SHA, declares the required caller + token permissions, preserves its actual default-branch and schedule triggers, + repairs documentation, and proves caller-context SARIF behavior with a governed + canary. `wardnet#160` and `semantic-data-portal#93` remain open repair branches + until that successor evidence exists. diff --git a/CHANGELOG.d/20260903-scheduler-rate-limit-fail-fast.md b/CHANGELOG.d/20260903-scheduler-rate-limit-fail-fast.md new file mode 100644 index 0000000000..a98ac92c1c --- /dev/null +++ b/CHANGELOG.d/20260903-scheduler-rate-limit-fail-fast.md @@ -0,0 +1,8 @@ +## Changed + +- PR review merge scheduler의 구현을 안정된 CLI/import facade와 core 모듈로 분리했습니다. +- GitHub primary rate-limit 소진 시 reset 조회와 최대 약 180초의 runner-held sleep을 + 제거하고 첫 실패에서 조직 sweep의 defer 경계로 즉시 반환합니다. +- 일시적인 server error·timeout에는 기존의 짧고 제한된 transport retry를 유지합니다. +- rate-limit 요청 1회·sleep 0회, legacy import·monkeypatch 호환성을 회귀 테스트로 + 고정했습니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index ac1985d86f..bf192f6a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,243 @@ +### Failed-check finding names the Strix sandbox instead of the gateway + +- `opencode-review-dispatch.yml`'s `emit_strix_provider_failure_finding` rendered one fixed finding for every `STRIX_PROVIDER_UNAVAILABLE` line, whose Root cause read "The contextual-orchestrator gateway or its discovered provider pool was unavailable for this run". `#1953` had just given the Strix sandbox bootstrap failure its own second verdict token (`STRIX_SANDBOX_UNAVAILABLE`) precisely because that attribution is wrong for it -- the sandbox container never reaches its Caido proxy, so the run dies before the gateway serves anything -- and this consumer re-applied the wrong attribution one step downstream, into the review findings and the failure census. The emitter now branches on the second token: a sandbox verdict gets a finding that names Strix's sandbox, says the verdict does not name the gateway, and tells the reader not to change gateway or provider configuration on its strength. A `STRIX_PROVIDER_UNAVAILABLE` line without the token keeps its existing text verbatim, so the gateway class has no regression surface. No test covered this finding text at all before (`gateway or its discovered provider pool` matched nothing under `tests/`); `tests/test_opencode_dispatch_strix_sandbox_finding.py` now runs the production emitter from the published run block and pins both directions plus the no-signal case. Refs #1953, #1935. + +### Strix gate keeps a recovered transient model error from failing a completed scan + +- `scripts/ci/strix_quick_gate.sh` `sanitize_known_strix_report_warnings` now also strips strix-agent's `strix.core.execution: transient model/provider error for ; replaying turn (attempt n/m, backoff Ns): …` WARNING lines before the report failure-signal scan. strix-agent 1.5.3 (`strix/core/execution.py:763`) emits that line only inside its bounded transient-retry branch, immediately before the replay runs; an exhausted retry logs `agent run failed for …; marking failed` at ERROR with a traceback and exits non-zero, and both of those still fail the gate. Observed on `.github#1689` run `34013778497`: a completed 63-minute scan (`run.json` `completed`, SARIF 0 results, attempt exit 0) was failed closed as `STRIX_PROVIDER_UNAVAILABLE … exhausted` on three such warnings, and the scheduler then dispatched another same-head scan. The pattern is anchored before the exception repr so the same class keeps matching after a gateway pin advance changes the exception type; re-verify the message format on every strix-agent bump. One documented side effect: when a provider's 503 body appears only inside a retry line's exception repr, removing that line also removes the only text `has_strix_report_provider_failure_signal` would have matched in the report log, which can make `is_model_retryable_error`'s report-only branch read a genuine outage as non-retryable. The direction is fail-closed (an exhausted retry still exits non-zero with its ERROR and traceback retained), and with a contextual-orchestrator primary the verdict branch answers before that classifier is consulted, so no path today changes its outcome; if fallback-model classification is ever wanted for a non-gateway primary, read the pre-sanitize attempt copy that `preserve_attempt_log` already keeps. Tests: `tests/test_strix_recovered_transient_sanitizer.py`. + +### Review sidecar preflight postpones a rate-limited account's candidates instead of banning them + +- `_preflight_review_agents` no longer ends its walk when every credential account has answered 429 twice in a row. A candidate set aside by `REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429` is postponed to the end of the walk, and once the first pass ends with the readiness target unmet and probe budget left, the postponed candidates are probed in catalog order until the sixteen-probe budget is spent. On 2026-09-06 five sidecar boots whose probes began between 07:24Z and 08:05Z read `probed 6 / skipped 18 / ready 0` and failed closed: `.github` run 34016207820's six probes across all three accounts were refused 429 between 07:49:35.111Z and 07:49:35.767Z, so the rule set every account aside on two same-account requests about 310 ms apart and gave up with ten of sixteen probes unspent — and because deferral needs one ready route, nothing was served either; `keyverse#143`'s 08:20Z `noema-review` repeated it in a second repository (six probes, 369 ms, all 429). The pools are not dead in those minutes: run 34016093772 was inside its own preflight during that burst, and its `llama-3.2-11b` probes on the same two NVIDIA keys answered ready at 07:50:58.7Z and 07:50:59.0Z, 84 seconds after those keys refused. Whether the unspent probes would have found a ready route inside a burst is unmeasured and is not claimed; the change is justified by ending a walk under target with the budget in hand. Of the fourteen boots that ran the merged rule, eight spend all sixteen probes in the first pass and are unchanged; one (`argos` 34014143870, a serving boot at `12 / 12 / 3`) exhausts its candidates under budget and now gains a second pass, as do the five burst boots. The cost is stated rather than assumed: a refused probe costs about 120 ms, a silent one up to the 90 s receive timeout, and the postponed tail holds both (`google/gemma-4-31b-it` answered `TimeoutError` in 15 of the 19 probes that reached it), so the worst case adds up to about 15 minutes to a boot that still fails and the two-stage auto path goes from 8 to 24 requests including the priced stage. The second pass never draws on the shared escalation budget, so the priced fallback keeps the escalations it had. The report gains `postponed_probed_count` (`skipped_count` now counts postponed candidates the budget never reached) and, on a refused probe, `retry_after_s` when the response carried a whole-seconds `Retry-After` header — evidence only, nothing waits on it, so the next census can decide whether a delayed second pass is worth proposing. ADR-0029 is amended. Refs #1948, #1949. + +### Superseded OpenCode review dispatches coalesce before they take a runner + +- `opencode-review-dispatch.yml` now carries a workflow-level `concurrency` group keyed by the dispatched pull request (`opencode-review-dispatch--`, `cancel-in-progress: true`), matching `codeql-scan-dispatch.yml`'s workflow-level group and the rationale already recorded in `strix.yml`, `noema-review.yml` and `opencode-review.yml`: a job-level group is never evaluated while the whole run waits behind the organization job ceiling. The workflow kept its group only on the long `opencode-review-target` job, so two dispatches for one pull request each queued for hours and each was allocated a runner before the older one could be discarded. Measured on 2026-09-06: four of the five dispatch runs that passed `validate-pr-metadata` were rejected hours later by the privileged metadata check because the head had moved while they queued (runs `34002473295`, `34010256951`, `34015973300`, `34016922761`), each after `coverage-source-tree` and `coverage-evidence` had run. The privileged check itself is unchanged -- it rejected exactly what it should; what changes is that the superseded run is now cancelled at creation instead of spending a slot to discover its subject moved. + +### Strix gate names the sandbox bootstrap failure and retries it once + +- `scripts/ci/strix_quick_gate.sh` gives the Caido sandbox bootstrap race (`loginAsGuest failed after 10 attempts` on `127.0.0.1:`, upstream usestrix/strix#1036/#1037/#1056) its own bounded same-model retry budget, `STRIX_SANDBOX_BOOTSTRAP_RETRIES` (default 1), drawn on top of `STRIX_TRANSIENT_RETRY_PER_MODEL`. That budget is 0 in production because the gateway owns model failover, so the documented sandbox retry never ran: `argos` Strix run 34013128112 (2026-09-06) shows one attempt, `Docker image ready`, the proxy never reachable, Strix exiting after 240 s -- while the sidecar reported four ready and four deferred routes that were never called. The budget is charged in the same branch that grants the attempt, so a log matching the sandbox class together with a gateway class cannot extend the loop without charging it (caught by adversarial review of the first draft). The primary-scan verdict for that class now reads `STRIX_PROVIDER_UNAVAILABLE: STRIX_SANDBOX_UNAVAILABLE: the last Strix attempt ended in the sandbox bootstrap (...) after N sandbox-specific same-model retries (budget B); this verdict names Strix's sandbox, not the LLM gateway.` instead of `orchestrator/free exhausted`, stating only what the gate observed; the leading token is unchanged so the workflow's finding-free classification and its tests are untouched, and the second token lets the review census split sandbox outages from gateway ones (two of six recent Strix artifacts were this class). Refs #1948. + +### Review sidecar preflight fills the served set lazily to a readiness target + +- `_preflight_review_agents` now treats the catalog as a candidate list, probed in its tier-then-round-robin order until `REVIEW_PREFLIGHT_TARGET_READY = 8` routes are ready or `REVIEW_PREFLIGHT_MAX_PROBES = 16` probes are spent (ADR-0029). The two-stage candidate budget rises from 12 to 24 (`REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES`; auto pool split 16 free / 8 priced; the sidecar's and the launcher's `ORCHESTRATOR_CATALOG_LIMIT` defaults follow), the production `free` pool lists all 24 (12 before), and the per-account cap stays 8. An account that answers 429 to `REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429 = 2` consecutive probes has its remaining candidates skipped without a probe (a 429 is a per-key answer), so the probes it would have spent reach the other accounts' next candidates — under the real 2026-09-06 order that is the difference between about five ready routes and the target of eight — and a fully rate-limited hour costs two probes per account instead of the whole budget; the report gains `skipped_count` and `account_skip_after_429`. The sidecar's job-log echo of the preflight JSON grows from 160 to 400 lines so 16 probed routes are not cut off exactly in the dead hour the summary matters. A permanently dead candidate -- NIM lists `gemma-3-12b`/`gemma-3-4b` and answers 404 on every run -- now costs one probe instead of a served slot, and a healthy pool stops early instead of always probing every candidate. Motivation: after #1939's four-per-account slice each NVIDIA key's slots were its first four models alphabetically, two of them those 404s, so preflight readiness fell from 6/12 to 1–3/12 and `noema-review` on this repository went from 7 successes / 14 failures to 0 / 22. The report gains `candidate_count`, `target_ready` and `probe_budget`; `probed_count` counts probes actually sent. ADR-0003's stage-budget sentence is amended. Refs #1939, #1947, #1948. + +### Sidecar sanitizer keeps the exception type and innermost frame per traceback + +- `scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py` now reduces each Python traceback in the sidecar stream to one line, `unexpected_exception type= frame=contextual_orchestrator/.py::` (the type identifier and the innermost package frame only; the exception message, source echoes and non-package frames are never re-emitted; a traceback cut off by the sidecar dying or without a package frame reports `unknown`). The previous single, once-per-stream `sidecar emitted an unexpected exception` line kept neither the count nor the type: `.github#1812`'s strix run (33993155419) ended on 83 gateway `500 internal_error` responses -- the orchestrator's generic request handler prints one traceback per unhandled exception -- and no artifact could say which exception escaped or where. Chain sentences (`During handling of the above exception…`, `The above exception was the direct cause…`) are consumed, so a chained exception yields cause then effect. +### Contextual-orchestrator pin advance fixes orchestrator/free retry-stacking + +- Advanced the central sidecar's pinned immutable CO revision from `2e414d15` to protected `main@414f22973658c4ddc3d4320fcf7acd9b4e8ba991`, carrying contextual-orchestrator#1081's fix into Strix, OpenCode, and Noema. Root cause: `TaskOrchestrator._invoke`'s own retry-then-failover decision for a retryable 5xx (budgeted `1 + tool_retry_attempts` real tries per candidate) was getting multiplied by `ModelClient._send_with_retry`'s independent transient-retry-with-backoff underneath it (`max_retries + 1` further tries per call) -- up to 6 real network attempts against one already-flagged-flaky `orchestrator/free` agent before `_invoke` ever tried the next ranked candidate. Confirmed as the cause of independently observed incidents in #1912, #1231, #1503, and #1198, each spending 9-57+ minutes on one escalated route and surfacing that same route's model in its final error, never reaching a cleanly-ready sibling preflight had already found. The fix (`ModelClient.single_attempt_transport()`) changes only which agent gets tried next; no per-attempt timeout changed. Reproduced the bug directly against unmodified contextual-orchestrator `main` before the fix (6 real attempts) and confirmed the fix resolves it (<=2) before advancing this pin. `docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md`'s 2026-09-06 amendment and `tests/test_contextual_orchestrator_review_sidecar_contract.py`'s `ORCH_PIN_SHA` were updated alongside this pin. All callers still consume an exact SHA; no branch or tag is introduced. + +### Review sidecar preflight keeps transient-rejected routes as deferred failover + +- `_preflight_review_agents` no longer discards a route whose 16-token probe answered with a status the serving gateway itself retries and fails over across (`408 409 425 429 500 502 503 504 529`, the vendored orchestrator's `TRANSIENT_HTTP_STATUS`). Such routes are kept as **deferred**, ranked after every ready route by a catalog-priority penalty, so a stalled or rate-limited ready route has somewhere to fail over to; `ready_count` is unchanged, a new `deferred_count` is reported, and `rejected_count` covers only routes the gateway would not retry either (404, auth failures, invalid responses). With no ready route the stage still fails as before, so ADR-0005's priced-catalog fallback contract is untouched. Motivation: `noema-review` run 33993637015 (2026-09-05) rejected 11 of 12 routes -- six with 429, three of them on NVIDIA keys whose sibling routes were ready -- served the single ready route for 542 s and returned 502; under this rule the same run would have served 1 ready + 6 deferred. The sanitized stream gains a `preflight_route_deferred` line alongside `preflight_route_rejected`. + +### Noema review ships sidecar evidence on failure + +- `noema-review.yml` now uploads `strix_runs/contextual-orchestrator-sidecar.stderr.log` and `strix_runs/contextual-orchestrator-preflight.json` as the `noema-sidecar-evidence` artifact when the verdict phase fails (`if: failure()`, the same pinned `actions/upload-artifact` Strix uses, `if-no-files-found: ignore`, 5-day retention). Until now a failed Noema run left `artifacts=0` -- run `33981136873` spent 3122 s walking six ready routes twice each and ended in HTTP 502 with no per-route trace anywhere but the sidecar's stderr -- so the only diagnosis available was the caller's one-line summary. The stderr file is the sanitizer's bounded allowlist output (`sanitize_contextual_orchestrator_sidecar_stream.py`), the same file Strix already publishes in `strix-reports`; per-attempt route outcomes still need an allowlisted structured line from the orchestrator to appear in it. Refs #1935, #1939. +### Sidecar sanitizer admits orchestrator route and circuit events + +- `scripts/ci/sanitize_contextual_orchestrator_sidecar_stream.py` now passes the orchestrator's own `provider_attempt`, `provider_attempt_failed` (cut before the free-text `error_message=`), `provider_backoff`, `provider_exhausted`, `provider_rejected_permanent`, `provider_no_retry_budget` and `circuit_failure|opened|reset|cleared` lines (whose `failures`/`reset_seconds` are floats at runtime, `2.0`/`30.0`), matched field by field against bounded identifier and number charsets, with either Python's default `LEVEL:name:` prefix or the sidecar formatter's `asctime LEVEL name` prefix (the timestamp is kept so per-route durations can be read as differences). Until now every one of these lines was folded into `omitted_unstructured_lines`, so the `provider_exhausted` WARNING that already fires today after a route's retry budget is spent never reached an artifact, and a 3122 s walk across six ready routes (run `33981136873`) had no per-route trace. Companion to #1943 (sidecar DEBUG logging) and #1944 (Noema uploads the file on failure). Refs #1935, #1939. +### Review sidecar records the orchestrator's per-attempt trace + +- `contextual_orchestrator_review_launcher.py` now configures the orchestrator process's logging before serving (`_configure_sidecar_logging`, calling the vendored `contextual_orchestrator.debug_logging.configure_logging`), defaulting to `DEBUG` with a timestamped format and overridable through `ORCHESTRATOR_SIDECAR_LOG_LEVEL`. The orchestrator logs every provider attempt, its classified failure, backoff, and circuit event at `DEBUG` and only `provider_exhausted`/`circuit_opened` at the default `WARNING`, so a failed review left no way to see which routes were tried or how long each took: a 3122 s `noema-review` 502 on 2026-09-05 could only be attributed to "six ready routes, two retry layers, about 548 s per hop" by reading source, not the log. None of the `DEBUG` sites at the vendored pin carries prompt or response content, and the sidecar already pipes this stderr through the redacting sanitizer before it is written to `strix_runs/contextual-orchestrator-sidecar.stderr.log`; a companion change uploads that file as a failure artifact. + +### Review sidecar catalog interleaves credential accounts + +- `build_zdr_prioritized_catalog` now fills each free/ZDR tier round-robin across independently credentialed accounts instead of in provider-name order. The sidecar exports `ORCHESTRATOR_CATALOG_ACCOUNT_CAP=8` with `ORCHESTRATOR_CATALOG_LIMIT=12`, and the sorted fill took 8 `nvidia_nim` routes and 4 `nvidia_nim_sub` routes before any `openrouter` route was reached, so a review that admitted 62 free routes across three accounts served a NVIDIA-only catalog (`noema-review` run 33969842312: `free_pool_admitted_routes` 62, `free_selected_count` 12, runtime preflight `ready_count` 2 of 12) and the failover loop had no other account to leave a stalled NVIDIA endpoint for -- the `noema-review` 502 class tracked in contextual-orchestrator#1045. Tier order (free before priced, ZDR before non-ZDR), the account cap, the limit, and the discovery-order independence contract are unchanged; the same input now yields 4 + 4 + 4. Contrasts with #1476, which hardens `_routable_discovered_models` against a pin that regresses the OpenRouter `evidence_only` flag: on the current pin (`2e414d15`, includes contextual-orchestrator#949) OpenRouter rows already reach the catalog builder, and the selection was what dropped them. + +### Scheduler holds pre-review branch updates while checks are in flight + +- `inspect_pr` now decides `wait` instead of `update_branch` when a behind, unreviewed head still has queued or running check runs (`has_in_flight_check_runs`, built on the existing `latest_check_runs`/`running_check_state`). Under a saturated runner queue each PR's own delayed `pull_request_target` scheduler run merged `main` into the head before review dispatch, cancelling every queued check on the old head (22/28 on #1926, 21/30 on #1484) and requeueing the PR at the back, so no head ever completed its checks: 76 of the 77 PRs merged into this repository since 2026-09-04 had 0/12 required contexts satisfied at merge time. The hold has no age cap on purpose -- a check that never finishes keeps the head in place instead of restarting that loop, and the update resumes once every newest check run is terminal. `CLAUDE.md` now describes both update paths. Tracked in #1935. + +### CodeQL scan dispatch matrix serialisation + +- Serialised the dispatched CodeQL matrix with `toJSON()` in `codeql-scan-dispatch.yml`. `codeql-pr.yml` sends `client_payload.matrix` as an array and the handler assigned it straight into `env:`, where a value must be a scalar, so GitHub rejected the step with "A sequence was not expected" and the dispatched scan never ran -- 0 successes against 136 failures since the handler was added in #1776. The validate step already consumes the value through `jq`, so JSON text is the shape it was written for and no consumer changes. Added a string contract test, because neither `yaml.safe_load` nor `actionlint` 1.7.12 flags this: it is an Actions template rule, so only GitHub's own validator rejects it and no local gate catches the class. + +### Contextual-orchestrator pin refresh + +- Advanced the central sidecar's default immutable CO revision to protected `main@2e414d15ba58f28597751b625a8a2f00fc9fadcf`, carrying current provider discovery, `orchestrator/free` workflow budget, web-search gateway, OpenCode Go, OpenRouter composition, and CI fixes into Strix, OpenCode, and Noema. The shared ModelClient default-timeout removal remains pending in contextual-orchestrator PR #1053. All callers still consume an exact SHA; no branch or tag is introduced. + +### Scheduler target admission + +- Added `ContextualWisdomLab/governance-risk-compliance` to the `OPENCODE_REPOSITORY_DISPATCH_TARGETS` repository variable directly (the actual source of truth for `ALLOWED_TARGET_REPOSITORIES` in both scheduler workflows) and removed the temporary hardcoded-literal bridge a prior commit had added to `pr-review-merge-scheduler.yml`/`pr-review-fix-scheduler.yml` to work around the variable not yet including it. Hardcoding a specific product repository into these shared scheduler workflows violates this repo's own thin-caller convention (`CLAUDE.md`: "Product hourly callers stay thin. Do not hard-code OriginWeave, aFIPC, naruon, or Keyverse into `pr-review-fix-scheduler.yml`") and broke `test_no_target_repository_is_hard_coded_in_the_shared_scheduler`. Updating the variable achieves the same admission with no code change and no test regression. + +### Hourly review-repair queue-scan bound + +- Raised `hourly-review-repair.yml`'s discovery ceiling from 50 to 200 while rotating deterministic 50-PR deep-inspection windows by hourly run number. The scheduler hydrates only the selected window and stops immediately after its single dispatch, preserving access to newer PRs without quadrupling expensive review/check/comment work. See `docs/doctoring/hourly-review-repair-single-file-consolidation.md`'s 2026-09-03 follow-up. + +## [Unreleased] +- Include merge-scheduler entrypoint, core, and regression-test changes in + the existing runtime-quality workflow's trigger and suite selector. Scheduler + workflow edits retain queue checks and also select the full review-repair + suite. Selector-only test edits use the existing unconditional contract step; + changelog-only edits still do not start this runner. No job is added. +- Complete the scheduler test isolation introduced by #1896 for the two + remaining fixtures that invoke `inspect_pr(..., dry_run=False)` or + `main(...)`. Both now stub the environment-gated startup-failure recovery + owner, so `GITHUB_ACTIONS=true` exercises the production guard without + issuing real GitHub calls or rejecting synthetic fixture SHAs. +- **Fix current-main contract drift that blocked the unscoped + `agent-review-runtime-quality-ci.yml` "Verify scheduler and + contextual-orchestrator review-repair contracts" step (which discovers and + runs the full `tests/` directory with no positional arguments).** First, + `strix.yml`'s `changed-scope` job had drifted from its byte-identical + siblings in `security-scan.yml`/`sast-semgrep.yml`: PR #1869's + `converted_to_draft` generalization folded its `if:` condition onto a + multi-line `>-` block scalar, and the extra continuation lines survived + `test_gate_job_is_byte_identical_across_the_five_workflows_apart_from_if`'s + `if:`-line-only normalization. Collapsed it back to one physical `if:` line + with the same expression -- no semantic change. Second, + `test_noema_close_cleanup_selects_only_the_closed_pr_across_shared_display_titles` + still looked up a step named "...for the closed pull request" and passed + `CLOSED_PR_NUMBER`, both retired by the same PR #1869 when it generalized + `noema-review.yml`'s `cancel-closed-pr-runs` cleanup step to "...for the + inactive pull request" (env renamed to `INACTIVE_PR_NUMBER`/ + `INACTIVE_PR_HEAD_SHA`/`PR_ACTION`) and added a `live_target_matches` + live-PR re-verification before every cancellation pass (mirroring + `strix.yml`'s identical job) -- `tests/test_noema_review_gate.py`'s + equivalent tests were already updated for this at the time, but this one + was missed. Updated the test to the current step name and env vars and + taught its fake `gh` to answer the new `pulls/` live-state lookup; + the PR #1507 "sibling Noema runs evade cancellation" `pull_requests[]` + matching invariant it protects is unchanged and still correctly + implemented in production. Third, + `test_dispatch_strix_reruns_scan_job_not_sibling_publisher` only mocked + `rerun_actions_job`, so in any environment with a real `gh` CLI on `PATH` + its `dispatch_strix_evidence` call still ran the genuine + `live_dispatch_head_matches` re-read, which invoked the unmocked `fetch_pr` + against the real GitHub API for a synthetic PR that does not exist there -- + returning a live/head mismatch and `"stale_head"` instead of the expected + `"rerun"` (and, absent `gh` entirely, failing even earlier with a missing + executable). Added `monkeypatch.setattr(sched, "fetch_pr", lambda *_args: + [pr])` alongside the existing `rerun_actions_job` mock so the live-head + check observes the same fixture `pr` as authoritative, matching how every + other call in this test path is already isolated from real GitHub state. + Fourth, the Strix shell contract still expected job-level concurrency after + PR #1878 moved same-PR coalescing to workflow admission; it now asserts the + admission-level key and rejects the obsolete delayed key. Fifth, the + consolidated review-recovery fixtures now use the 17 daily UTC schedules + adopted by main instead of the retired hourly expressions. +- Remove the central `org-queue-sweep` runner and its organization-wide + repository walk. Native PR/review events, auto-merge, trigger-aware + same-PR cancellation, and each repository's daily `scan-pr-queue` recovery + remain the bounded queue owners. +- Move Noema's repository-and-PR concurrency group to workflow admission so a + new HEAD cancels its stale queued run before either consumes a job slot. +- Scope the current-head coalescer's workflow admission to repository and PR, + while retaining exact-HEAD revalidation inside the trusted job. +- Align current-main workflow contract tests with native auto-merge completion, + validated dispatch concurrency keys, rotating queue pagination, globbed watch + paths, admission jobs, and the reviewed OpenCode dispatch blob. +- Restore the central Strix runtime after OpenAI Python 2.54.0 began importing + HTTPX2 by selecting the SDK's `httpx2` extra in the hash-compiled dependency + input. The required workflow now installs a verified HTTPX2 wheel before the + scanner starts instead of failing before analysis with a missing module. +- Move the exact-artifact SBOM attestation quality contract into the existing + agent review runtime selector and job, preserving Python 3.10 compilation, + Python 3.14 test evidence, exact-head checkout, hash locks, and read-only + permissions while removing the standalone workflow. +- Move the organization commercial-readiness contract suite into the existing + agent review runtime quality selector and job, removing its standalone thin + caller while retaining the reusable exact-head coverage implementation. +- Consolidate the standalone review-repair contract workflow into the existing + agent review runtime quality selector and job. Matching PRs now reuse one + checkout and dependency bootstrap while retaining the focused coverage, + docstring, compile, and exact-PR concurrency contracts. +- Remove repository-wide Actions-run inventory and cancellation from the daily organization PR recovery sweep. Native per-PR concurrency and the local exact-head coalescer remain the cancellation owners; the sweep now spends its API budget only on missed review, merge, and branch-update recovery. +- Retire the standalone OSV and Scorecard pull-request workflows after both scanners moved into the required `security-scan.yml`. The organization ruleset now has seven required workflow paths, and `.github` branch protection no longer requires the duplicate `osv-scan / osv-scan` context. + +- Add `.github/actions/orchestrator-free-sidecar`, an immutable composite-action boundary that checks out the exact central control-plane revision selected by `github.action_ref` and provisions the contextual-orchestrator `orchestrator/free` gateway. Provider bootstrap remains inside the central sidecar; callers receive only the gateway URL/token-file contract for the subsequent Agent step. +- Repointed 10 `scripts/ci/test_strix_quick_gate.sh` self-test assertions that had gone stale after the `pr_review_merge_scheduler.py`/`pr_review_merge_scheduler_core.py` facade/core split (#1803): they checked the now-98-line facade file for content (the exact-head branch-update guard, the squash-fallback retry, the subprocess-safety flags, the same-head Strix/OpenCode dispatch markers, and the `pr_head_ref` repository-dispatch payload) that lives in the core module instead, so they had been silently failing on every run since the split. The same repair aligns the wake-workflow list and daily recovery assertions with the current event-driven scheduler contract. A coverage/docstring version of the same gap was already fixed via #1810; this bash contract script was missed. +- **Fix the `coalesce` required check crashing instead of exiting cleanly for a superseded queued run.** `current-head-run-coalescer.yml`'s own design comment documents that `current_head_run_coalescer.py` raising `CoalescingRefused` (its remembered head no longer matching the PR's live head) is "a safe no-op" — but `main()` only ever called `coalesce()` directly, so the exception raised by `coalesce()`'s own top-level live-PR-state check propagated uncaught and crashed the job with exit code 1, instead of the intended graceful no-op. Reproduced live on `ContextualWisdomLab/.github#1503` (run `33766056421`, job `100684095620`): a stale queued run drained from the org-wide Actions capacity backlog against an already-superseded head failed the required `coalesce` check with `CoalescingRefused: pull request head moved before duplicate classification`. `main()` now catches `CoalescingRefused` specifically and exits 0 with an informational message; any other exception (malformed identity, an unavailable GitHub API) still fails closed. +## 2026-09-02 — Noema single-request gateway ownership + +- Removed the repository-owned 900-second repair deadline and duplicate model repair call from Noema. The GitHub Actions caller now issues one structured-output request while `contextual-orchestrator` owns repair/failover/timeouts. +- Hardened serving-model telemetry against control-character/workflow-command injection and lone-surrogate encoding failures, restored actionable exact changed-line diagnostics, and constrained local trailing-comma repair to complete JSON values. +- Added permanent single-request/no-fixed-timeout regressions and retired obsolete deadline/retry fixtures. +- Documented the RCA boundary for the historical Noema 900-second repair deadline and distinguished it from the three 900-second sandboxed test-command limits in `opencode-review-dispatch.yml`; future telemetry must retain phase and failure class for request-too-large, discovery, rate-limit, provider transport, malformed-output, stale-head, and sandbox-command failures. + # Changelog +- **Consolidate current-head queue coalescing into the merge scheduler.** The standalone `Current Head Run Coalescer` duplicated one runner admission for every central pull-request event. Its exact-head worker now runs inside the already-required merge-scheduler job after immutable trusted-source materialization, preserving fail-closed PR/head/base revalidation while deleting the redundant workflow job. + All notable changes to the organization automation repository are documented in this file. The format follows Keep a Changelog, and versioned releases follow Semantic Versioning where the repository publishes a release. ## [Unreleased] +- **Pin `opencode-review-dispatch.yml` off the starved floating `ubuntu-latest` image.** + The 2026-09-01 floating-image fix (see that entry below) pinned `strix.yml`, + `opencode-review.yml`, and `noema-review.yml` -- the three required-check + gates -- to explicit `ubuntu-24.04`, and explicitly flagged "any remaining + unpinned central workflows" as an open follow-up. `opencode-review-dispatch.yml` + is the workflow the required `opencode-review` check's own `repository_dispatch` + lands on to actually run the OpenCode CLI and post the exact-head verdict; all + 4 of its jobs still requested the floating image, so a starved runner here + queues the real review work for hours just as surely as on the required check + itself. Confirmed live on `contextual-orchestrator#1017`: its dispatch run + (`33916313804`) sat `queued` with no runner assigned from creation, and a + 30-run sample of recent `opencode-review-dispatch.yml` runs org-wide showed + 14 still `queued` (several 10+ hours old) and 0 clean successes. Pinned all 4 + occurrences to `ubuntu-24.04`, matching the established pattern exactly, and + extended `tests/test_required_review_runner_image_contract.py` (already + refactored to a shared `assert_explicit_supported_image` helper by concurrent + work) with a fourth case for this file. +- **Catch scheduler target-list drift before it silently fails an hourly heartbeat.** `hourly-review-repair.yml`'s per-cron `target_repository` matrix and the `OPENCODE_REPOSITORY_DISPATCH_TARGETS` repository variable (which gates `ALLOWED_TARGET_REPOSITORIES` in `pr-review-merge-scheduler.yml`/`pr-review-fix-scheduler.yml`) are two independently hand-maintained lists with no structural link -- three repositories (`governance-risk-compliance`, `nonnest2`, `quarantine-sandbox-runtime`) were added to the hourly matrix without a corresponding variable update, so their hourly heartbeat failed closed with "target repository is not allowlisted" until each was found and fixed the same day. Added `scripts/ci/opencode_repository_dispatch_targets.json`, a hand-maintained mirror of the variable's live value, and a new contract test (`test_every_hourly_caller_target_is_in_the_dispatch_targets_mirror`) asserting every hourly-caller target is present in it, so a future PR that repeats the omission fails at review time instead of at the next silent hourly failure. See `docs/doctoring/scheduler-target-list-drift-20260902.md`. +- **Fix a stale `test_strix_quick_gate.sh` assertion left broken by the `#1630` + scheduler-cadence lengthening.** `pr-review-merge-scheduler.yml`'s repository-local + heartbeat was changed from a quarter-hourly `cron: "*/30 * * * *"` to an hourly + `cron: "30 * * * *"` (see `docs/doctoring/actions-queue-saturation-hourly-sweep.md`), + and the Python regression `tests/test_actions_queue_saturation_scheduler_cadence.py` + was updated to match at the time — but the parallel bash contract in + `scripts/ci/test_strix_quick_gate.sh` still asserted the literal old string, so + every PR whose required `exact-head-path-policy` check ran this script against a + current `main` checkout failed on an assertion the workflow file itself could no + longer satisfy, regardless of the PR's own diff. Updated the assertion to the + current cron string and corrected an adjacent stale "15-minute organization sweep + / 30-minute scheduled scan" description to the current hourly/hourly cadence. + Verified: `bash scripts/ci/test_strix_quick_gate.sh` now passes against unmodified + `main` (confirmed failing before this fix, on the same clean clone); full suite + unaffected (2600+ passed, 100% coverage, 100% docstrings) since this is a + bash-only assertion string with no Python-side counterpart to update. +- **Consolidate the two genuinely duplicate quality-CI callers behind one reusable + `workflow_call` gate; leave the other six alone.** An audit of the 8 + `.github/workflows/*-quality-ci.yml` bootstrap-templated files found only one pair — + `javascript-coverage-quality-ci.yml` and + `organization-commercial-readiness-loop-quality-ci.yml` — where the shared skeleton + (checkout at the exact PR head, an identical pinned six-package mini-requirements + heredoc, `coverage run --branch -m pytest --import-mode=importlib`, `coverage report + --fail-under=100`, `compileall`, `git diff --exit-code`) was byte-for-byte the same + logic with only the timeout, pytest target, and coverage `--include` path varying per + subsystem. Extracted that shared shape into a new + `.github/workflows/exact-head-coverage-quality-gate.yml` reusable workflow + (`workflow_call`-only, four required inputs: `timeout_minutes`, `pytest_target`, + `coverage_include`, `compileall_targets`) and turned both callers into thin + `uses:`/`with:` wrappers. Verified first that no branch-protection required status + check or the org's required-workflow ruleset references either caller's job name + (`exact-head-coverage-contract` / `exact-head-policy`) before restructuring, so nothing + downstream depends on their exact shape. Updated the three contract tests that pinned + the old inline text + (`test_organization_commercial_readiness_loop_policy.py`, + `test_organization_commercial_readiness_loop_import_contract.py`) to check the + coverage/exact-head mechanics against the shared gate file and the subsystem wiring + against each caller, and added + `tests/test_exact_head_coverage_quality_gate_contract.py` to pin the gate's own + `workflow_call` contract and both callers' input wiring. The other 6 files + (`agent-mention-router-quality-ci.yml`, `exact-artifact-sbom-attestation-quality.yml`, + `noema-token-lifetime-quality-ci.yml`, + `opencode-rust-coverage-toolchain-quality-ci.yml`, `strix-changed-path-quality-ci.yml`, + `trusted-uv-materializer-quality-ci.yml`) look superficially similar but each encodes a + genuinely different policy -- harden-runner presence, a docstring/interrogate gate, + exact-head-verification mechanics (or, for noema, no `ref:` pin at all), multi-Python- + version matrices with non-shared extra logic (a tomli-fallback exercise, a Python 3.10 + compile-only contract), or no `coverage --fail-under` step at all (strix delegates to a + bash gate script instead) -- so templatizing them would either weaken what they + individually enforce or need enough per-caller toggles to defeat the point of sharing. + Left untouched, matching the precedent already set for ruling out the agent-mention + dispatch pair and the noema/opencode/strix "cancel superseded runs" jobs. Full suite: + 2603 passed, 1 skipped, 100% branch coverage, 100% docstrings, `actionlint` clean. - **Fail closed before cancelling stale PR workflow runs.** Validate snapshot `headRefOid` and re-read live PR/run identity immediately before destructive cancellation, including OpenCode/Strix dispatch cleanup, so a missing head or concurrent push cannot cancel the sole current-head evidence or trigger a duplicate review. Also ensures every cancellation path (`cancel_stale_pr_runs`, `cancel_stale_opencode_runs`, `_cancel_revalidated_review_run_refs`) treats a run as cancelled only when `force_cancel_workflow_runs` actually reports success, not merely when live revalidation proved it stale -- superseding PR #1712's simpler `force_cancel_workflow_run_refs` wrapper (removed as dead code; its safety guarantee is preserved inline at every call site by this more thorough revalidate-then-cancel design). - **Cache `active_workflow_runs` for the life of one `pr_review_merge_scheduler.py` invocation.** `inspect_pr()` calls `cancel_stale_pr_runs()` unconditionally for @@ -1108,6 +1341,22 @@ Semantic Versioning where the repository publishes a release. required-workflow placeholder. Conflicting heads and failed sibling jobs in an OpenCode workflow remain fail-closed alongside unresolved threads, Strix, coverage, and unrelated failed checks. +- Stop the organization PR sweep after the first exhausted shared GitHub App + installation bucket, rather than repeating up to three reset-aware waits and + follow-on queue-hygiene reads for every remaining repository. The current + target is recorded as deferred, the run remains non-fatal for this external + capacity condition, and later rotations retry the unfinished repository set. +- Close a gap in the above deferral: a shared-installation rate limit hit + mid-scan (inside a single PR's `inspect_pr()` call — an active-run read, + cancellation, dispatch, merge, or branch update — rather than the + once-per-repository `fetch_open_prs()`/`fetch_pr()` call before the loop) + previously fell back to an ordinary `action_error` decision and kept + scanning the repository's remaining PRs with the same exhausted bucket, + and returned exit 0, so the workflow's "API rate limit exceeded" + skip-and-defer branch — which only triggers on a non-zero sweep exit — + never saw it and later repositories in the same rotation kept spending + the bucket too. It now stops the repository's scan and propagates the + error like the pre-loop path already did. - Web verification now checks services through local readiness addresses only. Start the backend and frontend on this computer and use their local health URLs when running the check. diff --git a/CLAUDE.md b/CLAUDE.md index 12413c101c..30db1fc23b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,9 +48,10 @@ an actually-executed PoC via `scripts/ci/sandboxed_verify.py` or `scripts/ci/san split `Developer experience:` / `User experience:` sections). Deterministic code may repair only trusted `path:line` bindings on LLM probes that already carry an independent proof and source-line digest; it never invents observed -results. The scheduler updates a PR branch only -when the latest review is approved, no current-head check has failed, and GitHub reports the PR as -behind. The mechanical merge scheduler itself never synthesizes a fix: it gives `DIRTY`/`CONFLICTING` +results. The scheduler updates a PR branch in two cases: after approval, when no current-head check +has failed and GitHub reports the PR as behind; and before review dispatch, when the PR is behind and +no current-head check is still queued or running (an in-flight check is evidence the update would +discard; see #1935). The mechanical merge scheduler itself never synthesizes a fix: it gives `DIRTY`/`CONFLICTING` PRs repair guidance. A separate edit-capable autofix flow (`scripts/ci/pr_review_fix_scheduler.py` → `.github/workflows/pr-review-autofix.yml`) may, for an approved same-repository-head PR, merge the base into the head and resolve the conflict markers; the @@ -61,8 +62,8 @@ Details: `docs/pr-review-and-merge-procedure.md` and `PR_GOVERNANCE_AUDIT.md`. ## Structure - `.github/workflows/` — the central workflows. `pull_request_target`-triggered required workflows - (`opencode-review.yml`, `noema-review.yml`, `pr-review-merge-scheduler.yml`, `strix.yml`, - `close-empty-pr.yml`, …), security gates (`python-security.yml` bandit + pip-audit, + (`opencode-review.yml`, `noema-review.yml`, `pr-review-merge-scheduler.yml`, `strix.yml`, …), + security gates (`python-security.yml` bandit + pip-audit, `security-scan.yml`, `sast-semgrep.yml`, `secret-scan.yml`, `codeql-pr.yml`, `osv-scanner-pr.yml`, `scorecard-*.yml`, SBOM workflows), and reusable `workflow_call` workflows sibling repos call (`deploy-pages.yml`, `pr-review-fix-scheduler.yml`). @@ -127,6 +128,13 @@ repeatable compile command. workflow files (e.g. `test_pr_governance_audit_contract.py`, `test_codeql_pr_workflow_contract.py`, `test_opencode_workflow_shell_syntax.py`, `test_opencode_agent_contract.py`). Editing those files without running the test suite will break CI. +- **A "superseded" closure is a claim to verify, not accept.** See `AGENTS.md`'s "Verifying a + 'superseded — closing' claim" section. Two traps specific to this repo: use a **three-dot** + diff (`git diff --stat origin/main...`) — two-dot reports `main`'s own newer commits as + phantom deletions by a stale PR; and do not use `git merge-base --is-ancestor` as the test, + because this repo mixes squash merges with real merge commits, so it answers a different + question than "is this content on `main`". Narrowing a PR into successors is the same claim and + needs the same evidence. - **100% coverage and 100% docstrings on `scripts/ci/`** are hard gates, not aspirations. New helper code needs matching tests and docstrings. - **Product hourly callers** stay thin. Do not hard-code OriginWeave, aFIPC, naruon, or Keyverse @@ -148,7 +156,67 @@ repeatable compile command. breakout. Do not reintroduce bash fast-path extraction. - **Cloudflare changes are dry-run by default**; nothing is deleted unless `prune = true` is set explicitly. PRs never see the Cloudflare API token. +- **Required workflows ignore `on:` filters.** Org ruleset `18156473` runs the central workflow file + in each target repository's context and discards its `paths`, `paths-ignore`, `branches`, and + `types` there (confirmed live: `bandscope` has no local `codeql-pr.yml`/`strix.yml`/ + `security-scan.yml`, yet ruleset-injected runs of all three exist). `.github` is excluded from + that ruleset and instead uses classic branch protection with 14 named required contexts, where a + path-filtered workflow leaves its context Pending forever. Never add a trigger-level filter to a + required workflow; skip at job level via a `changed-scope` gate job instead, and always keep one + job with no output-dependent `if:` so the run concludes `success` rather than `skipped`. See + `docs/doctoring/required-workflow-path-filter-boundary.md`. +- **Narrowing a PR does not carry its delta automatically.** When a large PR is split into + successors, diff the union of the successors against the original before treating the supersession + as complete — each successor passing its own tests does not prove the union still covers the + original's scope. `#1871` → `#1877` + `#1879` silently dropped the coverage/docstring delta and + left the required gate broken on `main` until `#1883`. See AGENTS.md's "Supersession and + constant-change review". +- **Model-path timeouts are policy-fixed, not an engineering judgment call.** `docs/product-goal-directive.md` + section 8 accepts that central OpenCode/Strix/Noema may take more than two hours per model and states + that speed is not a core consideration. `#1889`/`#1890`/`#1892` each added a 900-second cap on genuine + multi-hour-hang evidence and were all reverted (`#1891`, `#1895`). Fix runner occupancy at the + admission/continuation boundary instead; never convert elapsed inference time into a model-failure + verdict. - **Org-wide binding conventions** (permissive licenses only — verify SPDX before adding anything; cross-repo references as `owner/repo#num` or full URLs; durable knowledge in the repo/Project, not private memory; one roadmap phase at a time) are defined in `docs/CWL-MASTER-CONTEXT.md` §7 and apply here. +- **Agent sessions here share one GitHub identity, so they cannot approve each other's PRs.** Every + session pushes and reviews as the same account, and GitHub refuses a review with `event=APPROVE` on + a PR that account authored (`POST /repos/{owner}/{repo}/pulls/{n}/reviews` → 422 "Can not approve + your own pull request"). This is not a formality to route around: `merge_approval_block_reason` in + `scripts/ci/pr_review_merge_scheduler_core.py` fails closed unless GitHub's `reviewDecision` is + `APPROVED` *and* `has_independent_current_head_approval` finds a non-author formal APPROVED review + on the exact current head. A verification comment documents evidence but satisfies neither + condition, so a peer session's review cannot unblock a merge — that needs a different identity or + the documented bypass path. Relatedly, `git log`/`merged_by` cannot attribute work to a session, so + read the diff before treating an unexplained commit on your branch as an intrusion. +- **`actions/runs?status=completed` is a misleading sample while the queue is churning.** When + cancelled/skipped runs are produced in bulk, a page of completed runs (default 30, so pass + `per_page=100`) can contain zero `success`/`failure` results and make the pipeline look dead far + longer than it is. Querying `status=success` and `status=failure` directly cuts through the churn + to the most recent real conclusion of each kind. Those are historical signals about pipeline + liveness only — they never substitute for exact-current-head evidence on the PR you are acting on. +- **Do not assume `interrogate` skips private helpers.** `[tool.interrogate]` here sets no + `ignore-*` flags and the tool defaults them off, so a docstring-less `_helper` or `__helper` in + `scripts/ci/` counts against the 100% gate — it is the stricter docstring check, not the laxer + one. Sibling repositories configure this differently (`contextual-orchestrator` enables six + `ignore-*` flags and does skip them), so read the target repo's `pyproject.toml` rather than + carrying a docstring habit across repositories. Note also that `ignore-private` would cover only + double-underscore names; single-underscore needs `ignore-semiprivate`. +- **A stale PR's conflict scope is a snapshot, not a property of the PR.** Any advance of the base + between measuring the conflicts and resolving them invalidates the list, and base advances land in + the same directories conflicts do (`.github/workflows/`, `scripts/ci/`, `docs/doctoring/`). Scope + grows as often as it shrinks — a branch that merged cleanly can become conflicted with no change + to the branch at all — so re-run the merge yourself immediately before resolving and treat any + earlier measurement, including your own from minutes ago, as expired. Resolving against a stale + smaller scope silently leaves conflicts unhandled. +- **No test parses fenced code blocks.** The doc-contract tests match exact prose in specific files; + none of them check Markdown structure, and `ARCHITECTURE.md` (five mermaid diagrams) is read by no + test at all. A conflict resolution that splits a fenced block into two fragments therefore ships + green, rendering the diagram source as a plain code block. After resolving a conflict in a + document containing fenced blocks, re-read the whole enclosing section rather than the diff hunk, + and confirm each block has one opening fence carrying its language tag and one matching closing + fence. Do not check by counting fences — a split leaves four where there were two, so an even + count proves nothing. The damage can also arrive inherited, from an earlier commit on the same + branch or from the autofix flow's conflict-marker resolution. diff --git a/PR_GOVERNANCE_AUDIT.md b/PR_GOVERNANCE_AUDIT.md index e1ab3ff02e..c6522ddd6a 100644 --- a/PR_GOVERNANCE_AUDIT.md +++ b/PR_GOVERNANCE_AUDIT.md @@ -462,3 +462,4 @@ PR #381: wait: OpenCode review is already in progress - `.github` PR #42 same-head OpenCode run `28070438305` exposed a second decode gap: model output reading tolerated invalid UTF-8, but approval-summary repair still read `OPENCODE_APPROVAL_REPAIR_EVIDENCE_FILE` as strict UTF-8. DeepSeek produced a repairable control block, then normalization failed on byte `0xea` in bounded evidence. Evidence repair now reads lossy UTF-8 so a damaged transcript byte cannot prevent source-backed normalization. - `codec-carver` PR #98 already has base `opencode.jsonc`. PR #98 now pins the central scheduler instead of downloading from `main`; same-head Strix run `28030439830` and OpenCode runs `28030438605`/`28030439065` were still in progress at the 2026-06-23 22:48 KST snapshot. - `.github` PR #38 exposed two central gaps after PR #37 merged: the `review_dispatch` reason lost the `same-head Strix and OpenCode dispatched` contract string, and `failed_status_checks()` treated failed PR-target Strix check runs as blockers even when a later manual `strix` status could supersede them. Commit `7be2d99` restores the reason string, materializes PR-head scheduler policy as non-executed data for Strix self-test, and ignores stale Strix check-run failures when the same head has a successful `strix` status context. Manual Strix run `28030448032` had passed self-test and was still running `Run Strix (quick)` at the 2026-06-23 22:48 KST snapshot. +- Required-workflow trigger-level `paths`/`paths-ignore` filters are a no-go (inert on 40+ ruleset-covered repos, merge-breaking on `.github`'s classic-protection contexts); the safe mechanism is a job-level `changed-scope` gate, and `codeql-pr.yml`'s `analyze-head` must gate at step level, not job level. Full live evidence and the fix: `docs/doctoring/required-workflow-path-filter-boundary.md`. diff --git a/README.md b/README.md index 5efa424819..1e9f51103a 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,8 @@ Checked-in operator facts: - Ruleset `18156473` is **active**. It targets every repository default branch (`~ALL` / `~DEFAULT_BRANCH`) and sources workflows from this repository at `refs/heads/main`. -- Active required workflow paths: `close-empty-pr.yml`, `noema-review.yml`, - `opencode-review.yml`, `pr-review-merge-scheduler.yml`, +- Active required workflow paths: `noema-review.yml`, `opencode-review.yml`, + `pr-review-merge-scheduler.yml`, `security-scan.yml`, `strix.yml`, and `sast-semgrep.yml`. - This repository itself is GitHub Flow on `main`. It is the central source, so it keeps the workflow files; siblings should not. diff --git a/config/repository-metadata.json b/config/repository-metadata.json index bb95527ee7..bce04b2dc2 100644 --- a/config/repository-metadata.json +++ b/config/repository-metadata.json @@ -4,133 +4,460 @@ "repositories": { "CalendarWeave": { "description": "CalendarWeave — governed calendar resources, iCalendar semantics, and interoperable scheduling infrastructure.", - "topics": ["calendar", "caldav", "icalendar", "scheduling", "rust", "contextualwisdomlab"], + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "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"], + "topics": [ + "psychometrics", + "assessment", + "measurement", + "longitudinal", + "research", + "privacy", + "rust", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "keyverse": { "description": "Keyverse — passwordless identity, federation, provisioning, account unification, and authorization services for ContextualWisdomLab.", - "topics": ["identity", "openid-connect", "oauth2", "scim", "keycloak", "python", "contextualwisdomlab"], + "topics": [ + "identity", + "openid-connect", + "oauth2", + "scim", + "keycloak", + "python", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "OriginWeave": { "description": "Let agents use the web without losing control. OriginWeave gives AI agents a Chromium-compatible web runtime with isolated sessions, typed actions, resource governance, and verifiable evidence.", - "topics": ["browser-automation", "ai-agents", "chromium", "security", "rust", "web", "contextualwisdomlab"], + "topics": [ + "browser-automation", + "ai-agents", + "chromium", + "security", + "rust", + "web", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "accounting-information-platform": { "description": "Accounting Information Platform — statutory accounting, journal posting, period control, reconciliation, and financial reporting authority for ContextualWisdomLab.", - "topics": ["accounting", "ledger", "journal", "reconciliation", "financial-reporting", "postgresql", "python", "contextualwisdomlab"], + "topics": [ + "accounting", + "ledger", + "journal", + "reconciliation", + "financial-reporting", + "postgresql", + "python", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "pg-erd-cloud": { "description": "PostgreSQL 스키마를 리버스 엔지니어링하고 ERD·DDL 공유 흐름으로 관리하는 클라우드 서비스.", - "topics": ["cloud", "database-schema", "ddl", "erd", "postgresql", "reverse-engineering", "saas", "python", "javascript", "contextualwisdomlab"], + "topics": [ + "cloud", + "database-schema", + "ddl", + "erd", + "postgresql", + "reverse-engineering", + "saas", + "python", + "javascript", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "clearfolio": { "description": "Clearfolio — secure document conversion, tenant-scoped viewing, and controlled artifact delivery.", - "topics": ["document-viewer", "document-conversion", "file-preview", "pdf", "java", "spring-boot", "javascript", "web-app", "contextualwisdomlab"], + "topics": [ + "document-viewer", + "document-conversion", + "file-preview", + "pdf", + "java", + "spring-boot", + "javascript", + "web-app", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "DiagramWeave": { "description": "DiagramWeave — a source-first, AI-assisted editor and tooling platform for PlantUML diagrams.", - "topics": ["diagram-editor", "plantuml", "developer-tools", "language-server", "javascript", "ai-assisted", "contextualwisdomlab"], + "topics": [ + "diagram-editor", + "plantuml", + "developer-tools", + "language-server", + "javascript", + "ai-assisted", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "semantic-data-portal": { "description": "Semantic Data Portal — governed discovery, graph traversal, and semantic search for enterprise data catalogs.", - "topics": ["data-catalog", "knowledge-graph", "ontology", "semantic-web", "semantic-search", "data-governance", "postgresql", "python", "contextualwisdomlab"], + "topics": [ + "data-catalog", + "knowledge-graph", + "ontology", + "semantic-web", + "semantic-search", + "data-governance", + "postgresql", + "python", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "contextual-orchestrator": { "description": "Contextual Orchestrator — an OpenAI-compatible control plane for model routing, delegation, verification, and multi-agent orchestration.", - "topics": ["enterprise-admin", "llm-orchestration", "model-orchestration", "model-routing", "ai-agents", "openai-compatible", "research", "python", "contextualwisdomlab"], + "topics": [ + "enterprise-admin", + "llm-orchestration", + "model-orchestration", + "model-routing", + "ai-agents", + "openai-compatible", + "research", + "python", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "noema": { + "description": "Noema — evidence-producing credential and maintenance control plane for governed GitHub automation.", + "topics": [ + "automation", + "code-review", + "control-plane", + "github-actions", + "github-app", + "llm", + "oidc", + "python", + "security", + "typescript", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "mhtml-etl-gateway": { "description": "Enterprise MHTML ingestion gateway that converts browser, SAP ALV, and Excel Web Archive exports into governed PostgreSQL data assets.", - "topics": ["mhtml", "etl", "data-ingestion", "sap", "postgresql", "data-governance", "python", "contextualwisdomlab"], + "topics": [ + "mhtml", + "etl", + "data-ingestion", + "sap", + "postgresql", + "data-governance", + "python", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "PolicyWeave": { "description": "PolicyWeave — local-first privacy-policy fact authoring, completeness review, and deterministic draft generation for web and app operators.", - "topics": ["privacy", "privacy-policy", "privacy-engineering", "policy-authoring", "local-first", "react", "typescript", "vite", "contextualwisdomlab"], + "topics": [ + "privacy", + "privacy-policy", + "privacy-engineering", + "policy-authoring", + "local-first", + "react", + "typescript", + "vite", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "supply-chain-control-plane": { "description": "Supply Chain Control Plane — evidence-backed supply-network dependency modeling and deterministic downstream disruption-impact analysis.", - "topics": ["supply-chain", "disruption-management", "dependency-graph", "provenance", "risk-analysis", "rust", "contextualwisdomlab"], + "topics": [ + "supply-chain", + "disruption-management", + "dependency-graph", + "provenance", + "risk-analysis", + "rust", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "learning-management-platform": { "description": "Learning Management Platform — enrollment, learning-journey, completion, and credential orchestration for employee and external learners.", - "topics": ["learning-management-system", "learning-platform", "enrollment", "completion", "credentialing", "rust", "postgresql", "contextualwisdomlab"], + "topics": [ + "learning-management-system", + "learning-platform", + "enrollment", + "completion", + "credentialing", + "rust", + "postgresql", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "learning-content-studio": { "description": "Learning Content Studio — evidence-bound LCMS for authoring, approving, releasing, and deterministically publishing reusable learning content.", - "topics": ["lcms", "learning-content", "content-authoring", "content-management", "accessibility", "scorm", "cmi5", "rust", "contextualwisdomlab"], + "topics": [ + "lcms", + "learning-content", + "content-authoring", + "content-management", + "accessibility", + "scorm", + "cmi5", + "rust", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true }, "learning-record-store": { "description": "Authoritative xAPI learning-record persistence for the CWL Learning Platform.", - "topics": ["learning-record-store", "xapi", "cmi5", "learning-technology", "interoperability", "contextualwisdomlab"], + "topics": [ + "learning-record-store", + "xapi", + "cmi5", + "learning-technology", + "interoperability", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "bandscope": { + "description": "로컬 우선 리허설 앱: 곡을 섹션·역할·템포·연습 우선순위로 분석합니다.", + "topics": [ + "audio-analysis", + "local-first", + "music", + "practice-tool", + "python", + "rehearsal", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "saju-caldav": { + "description": "saju-caldav — personalized Four Pillars calendars published through CalDAV and iCalendar.", + "topics": [ + "caldav", + "fastapi", + "four-pillars", + "icalendar", + "saju", + "python", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "governance-risk-compliance": { + "description": "Governance, risk, control, evidence, and compliance workflows with auditable policy and standards mapping.", + "topics": [ + "governance", + "risk-management", + "compliance", + "grc", + "audit", + "python", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "metering-billing-platform": { + "description": "Metering & Billing Platform — provider-neutral usage attribution, metering, rating, entitlements, invoice intent, and reconciliation.", + "topics": [ + "metering", + "billing", + "usage-based-billing", + "entitlements", + "reconciliation", + "finops", + "python", + "postgresql", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "learning-interoperability-contracts": { + "description": "Learning Interoperability Contracts — versioned learning schemas, profiles, mappings, and conformance contracts.", + "topics": [ + "learning-technology", + "interoperability", + "xapi", + "cmi5", + "json-schema", + "contracts", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "litellm-patched-proxy": { + "description": "litellm-patched-proxy — hardened downstream LiteLLM proxy images with bounded production patches and supply-chain evidence.", + "topics": [ + "litellm", + "llm-proxy", + "container-image", + "supply-chain-security", + "vulnerability-scanning", + "sbom", + "python", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "pingora-gateway": { + "description": "Pingora Gateway — a shared Rust edge runtime for explicit, bounded reverse-proxy traffic and secure service ingress.", + "topics": [ + "reverse-proxy", + "edge-computing", + "pingora", + "rust", + "network-security", + "observability", + "contextualwisdomlab" + ], + "deepwiki": true, + "pages": true + }, + "Veilpick": { + "description": "Veilpick — ontology-guided, policy-governed web acquisition and evidence-backed structured extraction.", + "topics": [ + "web-acquisition", + "data-extraction", + "ontology", + "provenance", + "rust", + "browser-automation", + "contextualwisdomlab" + ], "deepwiki": true, "pages": true } diff --git a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md index 217b3cc0b1..9b0749f258 100644 --- a/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md +++ b/docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md @@ -24,7 +24,7 @@ all five, and auto-optimize routing by cost. 1. **Vendoring, pinned**: `scripts/ci/contextual_orchestrator_review_sidecar.sh` clones `ContextualWisdomLab/contextual-orchestrator` at an exact SHA - (`045d17da5e2aea56a97e241ee158ab1628d78660` today) into `RUNNER_TEMP`. The + (`414f22973658c4ddc3d4320fcf7acd9b4e8ba991` today) into `RUNNER_TEMP`. The source's `requirements.lock` is installed with `--require-hashes` and `--no-deps`, so dependency resolution cannot silently move the reviewed runtime. @@ -50,9 +50,12 @@ all five, and auto-optimize routing by cost. route rejects the real runtime request contract does it rebuild once from fully price-attested routes and record the rejected primary attempt. This is evidence-triggered failover, not an arbitrary free/paid mixing ratio. - Both stages share one twelve-route startup budget: no more than eight routes - enter the free primary stage and only its remaining capacity may enter priced - fallback. Full discovery counts remain in policy evidence, and the transient + Both stages share one bounded startup budget of twenty-four candidates: no + more than sixteen enter the free primary stage and only its remaining + capacity may enter priced fallback. Candidates are probed lazily in catalog + order until eight routes are ready or sixteen probes are spent per stage + (ADR-0029), so a dead candidate costs one probe, not a served slot. Full + discovery counts remain in policy evidence, and the transient priced catalog is removed immediately after loading. 3. **ZDR-first within each cost tier**: `scripts/ci/zdr_policy.py` defines ZDR the way OpenRouter does ("a provider will not store your data for any period @@ -111,6 +114,12 @@ all five, and auto-optimize routing by cost. so this startup contract has no provider-egress or provider-availability dependency. +- **2026-09-02 amendment: advance the governed runtime pin to current CO main.** + The single sidecar default now advances from `045d17da5e2aea56a97e241ee158ab1628d78660` to the exact + `contextual-orchestrator` main revision `2e414d15ba58f28597751b625a8a2f00fc9fadcf`, which contains the + current provider-discovery and gateway contracts. The SHA remains immutable; + this is a reviewed dependency refresh, not a floating branch reference. + ## Consequences - The autofix/OpenCode review paths no longer hard-code any provider base URL @@ -250,3 +259,26 @@ all five, and auto-optimize routing by cost. fault. Accepted-size and tool-schema probes call the pinned client's deterministic mock response explicitly and therefore perform no provider call. +- **2026-09-06 amendment: advance the governed runtime pin to fix + `orchestrator/free` retry-stacking.** The vendored pin advances from + `2e414d15ba58f28597751b625a8a2f00fc9fadcf` to + `414f22973658c4ddc3d4320fcf7acd9b4e8ba991`, the commit that merges + `contextual-orchestrator#1081`. That PR fixes `TaskOrchestrator._invoke`'s + per-agent retry-then-failover decision (`RETRY_SAME_AGENT` for a retryable + 5xx, budgeted at `1 + tool_retry_attempts` real tries per candidate) getting + multiplied by `ModelClient._send_with_retry`'s own, independent + transient-retry-with-backoff loop underneath it (`max_retries + 1` further + tries per call) — up to `(tool_retry_attempts + 1) × (max_retries + 1)` real + network attempts (6 at production defaults) against one already-flagged-flaky + `orchestrator/free` agent before `_invoke` ever tried the next ranked + candidate. This is the confirmed root cause of independently observed + incidents in `ContextualWisdomLab/.github` PRs #1912, #1231, #1503, and + #1198, each spending 9–57+ minutes on one escalated route and surfacing that + same route's model in its final error, never reaching a cleanly-ready + sibling preflight had already found. The fix adds + `ModelClient.single_attempt_transport()` (a thread-local context manager + mirroring the existing `request_settings()` pattern) that forces + `_send_with_retry`'s retry budget to 0 for the duration of `_invoke`'s own + per-agent attempt; it changes only *which* agent gets tried next, never any + per-attempt timeout, consistent with the 2026-08-31 amendment above. No + other contextual-orchestrator behavior changes with this pin advance. diff --git a/docs/adr/0023-r-cmd-check-reusable-workflow-consolidation.md b/docs/adr/0023-r-cmd-check-reusable-workflow-consolidation.md new file mode 100644 index 0000000000..05f8b92f79 --- /dev/null +++ b/docs/adr/0023-r-cmd-check-reusable-workflow-consolidation.md @@ -0,0 +1,49 @@ +# ADR-0023: Consolidate kaefa/nonnest2 R-CMD-check.yaml into one reusable workflow + +- **Status:** Proposed +- **Date:** 2026-09-02 +- **Scope:** `ContextualWisdomLab/.github` reusable R package CI; consumers `ContextualWisdomLab/kaefa` and `ContextualWisdomLab/nonnest2` + +## Problem + +`ContextualWisdomLab/kaefa` and `ContextualWisdomLab/nonnest2` carry near-identical R-CMD-check workflows derived from the r-lib Actions examples. The shared sequence is checkout → Pandoc → optional TinyTeX → R setup → dependency setup → optional repository-specific regression → `check-r-package`. Copying that sequence creates action-pin, permission, and behavior drift. + +A first reusable-workflow implementation exposed the repository-specific regression as a free-form `pre_check_script` string and interpolated it directly into `run:`. Current-head security review correctly identified that design as a privileged-code boundary defect: a reusable caller could supply arbitrary shell source to a job that receives the caller repository token. Consolidation does not justify transferring executable authority from a consumer into a centrally trusted workflow. + +## Decision + +1. `ContextualWisdomLab/.github/.github/workflows/r-package-check.yml` is the canonical reusable owner for the shared R-CMD-check sequence. +2. The reusable interface is data/capability oriented, not shell oriented. It accepts: + - `r_matrix`: JSON strategy matrix; + - `needs_tinytex`: boolean capability; + - `extra_packages`: dependency input forwarded to r-lib Actions; + - `check_args`: R CMD check arguments; + - `install_package_before_pre_check`: boolean capability for the known kaefa regression shape; + - `pre_check_test_file`: repository-relative `tests/testthat/*.R` path passed as data. +3. Free-form `pre_check_script` is forbidden. The workflow owns the only executable pre-check commands: an optional fixed `install.packages(".", ...)` invocation and a fixed `testthat::test_file(Sys.getenv("PRE_CHECK_TEST_FILE"))` invocation. +4. `pre_check_test_file` fails closed unless it is a relative `tests/testthat/*.R` path and contains no parent traversal, absolute-path prefix, carriage return, or newline. The path enters the shell only through an environment variable; it is never evaluated as shell source. +5. Uniform security/supply-chain fields remain centrally owned and non-parameterized: `permissions: contents: read`, `GITHUB_PAT`, `R_KEEP_PKG_SOURCE`, `build_args`, `error-on`, upload behavior, and immutable action SHAs. +6. Consumer trigger branches remain in each repository's thin caller. Consumers must pin `uses:` to the immutable protected-main commit containing the reusable workflow; mutable `@main`, PR heads, and branch URLs are not production dependency authority. +7. The current proposal remains **Proposed** until this exact candidate passes repository tests/security/review and integrates through protected `main`. Only then may consumer PRs pin the resulting protected-main SHA and reacquire their own exact-head evidence. + +## Alternatives considered + +- **Keep copied workflows.** Rejected because two already-identical control surfaces drift independently and duplicate maintenance/security review. +- **Free-form shell input.** Rejected because it turns caller data into executable commands in a centrally trusted job. +- **Parameterize action SHAs or permissions.** Rejected because supply-chain and token authority belong to the reusable workflow owner, not individual consumers. +- **Hard-code kaefa-specific file names centrally.** Rejected because the reusable owner should expose the minimum bounded semantic input needed by multiple products, not own product test identity. +- **Consume an unreleased PR-head version from product callers.** Rejected because consumers may use only protected/released immutable owner contracts. + +## Invariants and failure scenarios + +- A malicious or compromised caller cannot make the central job execute arbitrary Bash through an input. +- An invalid test-file path fails before R execution. +- A caller cannot elevate token permissions through the reusable workflow. +- If protected-main publication has not occurred, consumer adoption remains blocked rather than falling back to a mutable ref. +- Changing the caller to a reusable job may change the published check-context name; consumer branch/ruleset requirements must be re-read before adoption and repaired at the owning ruleset rather than silently weakening protection. + +## Consequences and follow-up + +The central workflow becomes a small reusable CI contract while product repositories retain only triggers and bounded product-specific values. `ContextualWisdomLab/kaefa#84` must replace its former shell input with `install_package_before_pre_check: true` and `pre_check_test_file: tests/testthat/test-zh-misfit-decision-rule.R`, then pin the eventual protected-main SHA. `ContextualWisdomLab/nonnest2#119` must likewise pin the protected-main SHA. Both consumer PRs remain non-authoritative until the owner integrates and their own current-head gates pass. + +The executable regression in `tests/test_r_package_check_reusable_workflow_contract.py` permanently forbids reintroducing caller-authored shell source and verifies the bounded pre-check path. diff --git a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md new file mode 100644 index 0000000000..5a11894767 --- /dev/null +++ b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md @@ -0,0 +1,308 @@ +# 0025 — Restore central CodeQL as a required workflow via repository_dispatch + +**Status:** Proposed, amended 2026-09-07 (one dispatch per pull request; language independence is the handler job matrix) · **Date:** 2026-09-03 · **Owner intent recorded:** loop-brief item 41 + +## Problem + +`.github/workflows/codeql-pr.yml`'s `analyze-head`/`analyze-merge` jobs called +`github/codeql-action/init` and `github/codeql-action/analyze` directly. As of +this ADR, that file is **not** in the org required-workflow ruleset +(`18156473`) — it was removed as an emergency fix (see +`docs/doctoring/codeql-pr-required-workflow-always-fails.md`) after every +ruleset-injected run of it, across every sampled repository, ended in +`startup_failure` with zero jobs created. The reason, confirmed via the +GitHub web UI (the REST API exposes nothing) and independently corroborated +against GitHub's own community documentation +(github.com/orgs/community/discussions/69595, github.com/google/github-team#5): +**`github/codeql-action/init`/`analyze` are categorically disallowed inside +any workflow admitted through a ruleset's `workflows` rule type** ("required +workflows"). This is a platform restriction, not a configuration mistake — +no SHA pin or version bump changes it. + +Constraint confirmed during this investigation, load-bearing for the design +below: GitHub's admission check for required workflows appears to scan the +**entire workflow file** for disallowed actions before starting any job — the +observed `startup_failure` produced zero check runs, not just a failure of +the two jobs that actually call `codeql-action`. Any fix that keeps a +`codeql-action` reference anywhere in the required-workflow file, even in a +job that would never execute for a given event, will be refused at +admission. The fix must remove every `codeql-action` reference from the +required-workflow file itself, not merely gate it with an `if:`. + +Second constraint, also load-bearing: per GitHub's own documentation +("Required status checks do not take workflow, matrix, or event trigger +types into account... you must manually enter the exact check name +expected" — and, from the community discussion above, the ruleset's +`workflows` rule type tracks the **specified file's own execution**, not an +externally-posted check-run that merely happens to share a name) — the +required check for `codeql-pr.yml` can only be satisfied by a job that is +still literally defined *inside* `codeql-pr.yml`. A separate, unrelated +workflow cannot satisfy this required check by posting a same-named +check-run from outside; the job producing the required check-run identity +must remain part of the required-workflow file's own run. + +## Why not just rely on GitHub's native code-scanning default setup + +A parallel finding the same day (peer investigation, not part of this ADR) +enabled GitHub's native "code scanning default setup" on the 23 of 71 +ruleset-covered repositories that had no CodeQL coverage from any source. +That is real, working, per-repository coverage and should stay — but it is +not equivalent to what `codeql-pr.yml` provided and is not a substitute for +this ADR: + +- Native default setup's languages, query suite, and schedule are configured + **per repository**, not centrally by `.github`. This org's stated + preference is a single canonical owner for org-wide CI policy + (`docs/CWL-MASTER-CONTEXT.md` §7), not 71 independently-drifting + configurations. +- `codeql-pr.yml`'s Medium+ SARIF gate **fails the pull request check** on an + unsuppressed Medium-or-higher security finding; native default setup by + itself only creates code-scanning alerts, and making it a hard merge gate + again requires attaching its dynamic, per-repository `Analyze ()` + context names to `required_status_checks` — which is exactly the + centrally-unmanageable, per-repository configuration this org has tried to + avoid. +- `codeql-pr.yml` additionally scanned the **merge-commit preview** + (`analyze-merge`, catching issues introduced only by the merge itself), + which native default setup does not do at all. + +Native default setup is the right *baseline safety net* (and is now in place +everywhere); it does not replace a centrally-owned, hard-gating required +check. Both should coexist. + +## Proposed architecture + +Follow the same required-workflow-entrypoint-dispatches-to-native-execution +pattern already proven by `strix.yml` (`repository_dispatch` + +`Fetch pull request head for trusted scan` + `Publish same-head manual Strix +status`) and OpenCode's runner-release plus exact run/job wake-up contract. +Concretely: + +``` +codeql-pr.yml (required workflow, runs in target repo context) + detect-languages -- UNCHANGED: checkout PR head, detect languages + and changed-path scope. No codeql-action + reference; already admission-safe today. + dispatch-analysis -- NEW: exchange OIDC for an OpenCode app token + scoped to ContextualWisdomLab/.github + (identical exchange call already used by + opencode-review.yml's dispatch step), then + POST repos/ContextualWisdomLab/.github/dispatches + with event_type: codeql-scan and a payload of + {target_repository, pr_number, pr_head_sha, + pr_base_sha, matrix}. Re-validates live PR + state first (open, not draft-exempt in the + same way OpenCode's dispatch step already + does) before dispatching. + analyze-head (matrix) -- SAME REQUIRED-CHECK NAME: + "CodeQL compatibility analysis (${{ matrix.language }})". + No codeql-action reference and no + repository_dispatch. On attempt one it + re-checks the live head, consumes an + authenticated codeql-dispatch/ + status when one exists, and otherwise fails + pending to release the runner. The trusted + handler publishes the terminal status and + reruns only that failed job. On the woken + attempt the shard reads the authenticated + current-head status once and reflects it as + this job's own exit code. + dispatch-current-head -- NEW: needs analyze-head, runs on attempt one + of an open current-head PR after the shards + have job ids. Collects those ids from this + run's jobs API, POSTs event_type codeql-scan + once with the remaining language matrix and + required_jobs: [{language, job_id}, ...], and + fails closed if any shard job id is missing. + Skips the POST when every language already + has a terminal verdict. github.run_attempt == 1 + is required: a single-job wake re-runs + dependents, and a second POST would cancel + the in-flight multi-language handler. + +.github/workflows/codeql-scan-dispatch.yml (NEW, runs natively in .github, +NOT admitted through the ruleset, so codeql-action is unrestricted here) + on: repository_dispatch: types: [codeql-scan] + validate-dispatch -- Re-validate the payload against the LIVE pull + request in the target repository (identical + pattern to strix.yml's "Validate repository + dispatch against live pull request metadata": + reject if state/base/head don't match exactly). + scan (matrix over payload languages) + -- Exchange OIDC for a target-repo-scoped + OpenCode app token (identical exchange used + by strix.yml's target_app_token step). + Checkout the target repository's PR head at + the exact validated SHA (harden-runner + audited, matching strix.yml's checkout + posture). Run codeql-action/init + + codeql-action/analyze with upload: false + (same as today). Apply the Medium+ SARIF gate + (extracted to scripts/ci/codeql_sarif_gate.py + with its own unit tests, replacing the + current inline-Python duplicated between + analyze-head and analyze-merge -- one script, + one test file, used from both the merge + preview path if it returns and this dispatch + handler). + -- Publish the result as a commit status on the + TARGET repository at context + "codeql-dispatch/" using the + target-scoped token (identical mechanism to + strix.yml's "Publish same-head manual Strix + status" multi-token fallback chain), state + success/failure, description carrying a short + finding count, target_url pointing at this + .github run's own log for full evidence. + -- Upload the SARIF as an artifact on this + .github-side run for audit trail (mirrors + strix.yml's "Preserve CodeQL SARIF evidence" + / artifact retention today). + -- Re-fetch the open PR, exact required workflow + run, and exact failed language job; + require matching path/head/run/job/name before + calling the single-job rerun endpoint. Missing, + stale, closed, or mismatched identity fails + closed and leaves the required job failed. +``` + +### Concurrency identity is per pull request; language independence is the job matrix + +The required `analyze-head` matrix still publishes one named check per +language. It no longer POSTs. One `dispatch-current-head` job sends every +still-pending language in a single `codeql-scan` payload (`matrix` plus +`required_jobs`). The native handler's concurrency group is +`codeql-scan-dispatch-${target_repository}-${pr_number}` with +`cancel-in-progress: true`, so a newer HEAD of the same pull request cancels +its predecessor and other repositories or pull requests stay independent. + +Language independence is `strategy.fail-fast: false` on that one run's job +matrix. Each scan job still publishes `codeql-dispatch/` and wakes +only its own required job. One language's failure cannot cancel or skip a +sibling. + +#### 2026-09-07 amendment: one dispatch per pull request, adopted for the 60-job ceiling + +The 2026-09-05 per-language run was the right fix for the accident it +recorded. contextual-orchestrator PR #1049 dispatched three current-head +language jobs, and central run `33938784437` was the sole survivor because +the handler's group omitted `required_language`. Sibling runs cancelled one +another and left their required jobs failed in the `pending` handoff state. +Sending the full language matrix in one dispatch was rejected then because +the handler validated one shard and woke one exact required job per run; +enlarging that surface had no observed need. + +That need now exists. On 2026-09-07 the organization job ceiling (60 jobs) +was saturated by this fan-out: ContextualWisdomLab/.github had ~300 queued +runs, 149 of them `codeql-scan-dispatch.yml`, covering 60 PR@SHA tuples +(n=2:29, n=3:27, n=4:2). Duplicate cancellation could not collapse them: +the language is not present on the run name, the job name, or the REST +payload. The user-facing concurrency contract for pull-request workflows is +`{workflow}-{repository}-{PR}` with `cancel-in-progress: true` only for a +superseded HEAD of the same pull request, and a language suffix is +forbidden. + +The 2026-09-05 rejection of "full matrix in one dispatch" is therefore +superseded. The sibling-cancel failure mode is gone because siblings are +jobs in one run, not runs in one concurrency group. The exact-job wake +contract is preserved: `required_jobs` is a 1:1 map of language to canonical +job id, each scan shard looks up only its own id, and a missing, stale, or +mismatched identity still fails closed. The old scalar +`required_job_id`/`required_language` payload is retired. + +## Scope decision: `analyze-merge` is dropped, not migrated + +`analyze-merge` ("CodeQL merge preview") is confirmed, per PR #1766's own +commit message, **required nowhere** in the current ruleset. Migrating it to +the dispatch pattern doubles the size and risk of this change for a check +that gates nothing today. It is dropped in the first implementation of this +ADR; re-adding a merge-preview scan (dispatch payload already carries +`pr_base_sha`, so the merge-commit ref could be resolved the same way) is a +follow-up once the required `analyze-head` path is live and proven, not a +blocker for this one. + +## Security considerations (must be resolved during implementation, not assumed) + +- **Payload forgery / TOCTOU:** the dispatch handler must re-fetch the live + PR from the API and refuse to scan or publish anything if the dispatched + `pr_head_sha` no longer matches the live head, exactly like `strix.yml`'s + existing `Validate repository dispatch against live pull request metadata` + step and the exact-job wake-time revalidation. A forged or stale + dispatch must never be able to make an unrelated head appear scanned. +- **Cross-repository checkout trust boundary:** the scan step checks out + arbitrary target-repository PR-head content into `.github`'s own runner. + This is the same trust boundary `strix.yml` already crosses today (its + `Fetch pull request head for trusted scan` step) — reuse its harden-runner + posture and its "never execute PR content from the trusted base checkout" + invariant; the CodeQL scan only *analyzes* checked-out files, it does not + execute them, which is a narrower risk than Strix's own scanning already + accepts. +- **Status-publish credential scope:** the token used to publish the + `codeql-dispatch/` commit status must be scoped to `statuses:write` + on the *target* repository only, following the same per-repository + app-token minting `strix.yml` already performs — never a token with + broader org access. +- **Verdict target cannot be spoofed by the PR author:** a commit status is + writable by anyone with `statuses:write` on the repository (including, + depending on token scoping, a workflow running with the default + `GITHUB_TOKEN` in some configurations) — confirm during implementation + that the rerun job in `codeql-pr.yml` verifies the status update's + `creator`/`avatar_url`/app identity matches the expected dispatch-handler + app, not merely the context name, so a malicious PR cannot forge its own + passing status. `strix.yml`'s manual-status-publish step already documents + a similar concern; follow its precedent rather than trusting context name + alone. + +## Alternatives considered and rejected + +- **Attach native default-setup's `Analyze ()` names to a required + check centrally:** rejected — those names and languages vary per + repository, which cannot be expressed in one org-wide ruleset without + per-repository ruleset maintenance, defeating the centralization this org + has repeatedly chosen (`docs/CWL-MASTER-CONTEXT.md` §7, + `docs/doctoring/ci-workflow-duplication-audit-20260902.md`). +- **Leave `codeql-pr.yml` out of the ruleset permanently, rely on native + default setup alone:** rejected as the *only* answer — it silently drops + the hard Medium+ merge gate and the merge-preview scan this org + deliberately built; acceptable as an interim state (already in effect + since the emergency fix) but not the intended end state. +- **Ask GitHub support to lift the restriction:** not pursued — this is a + documented, evidently deliberate platform limitation + ("CodeQL requires configuration at the repository level"), not a bug + report candidate. + +## Risks and effects + +- Adds one new workflow file and one new `scripts/ci/codeql_sarif_gate.py` + module (with its own test file, contributing to the 100%-coverage + requirement on `scripts/ci/`) to the org's central CI surface — more + surface area to maintain, offset by removing ~70 lines of duplicated + inline Python between `analyze-head`/`analyze-merge` today. + exact run/job wake-up follows the OpenCode runner-release pattern while + avoiding one occupied runner per language for the scan's full duration. +- A repository and pull request have one active native handler run. Language + parallelism is bounded by the detected CodeQL matrix inside that run, and a + superseded HEAD of the same pull request cancels the in-flight handler + instead of queuing another copy per language. +- Re-admitting `codeql-pr.yml` to ruleset `18156473` must happen only after + this design is implemented, tested, and its `detect-languages`/ + `dispatch-analysis`/`analyze-head` jobs are confirmed free of any + `codeql-action` reference (grep the final file for `codeql-action` and + assert zero matches, as a permanent contract test) — re-adding it with + the bug still present would recreate the exact org-wide 100%-startup_failure + incident this ADR exists to prevent. + +## Follow-up + +1. Implement `scripts/ci/codeql_sarif_gate.py` + its test, extracted from + the current inline gate in `codeql-pr.yml`. +2. Implement `codeql-scan-dispatch.yml` per the design above. +3. Rewrite `codeql-pr.yml`'s `analyze-head` job into the dispatch+exact-job-wake shape; + delete `analyze-merge` (tracked as future work, not silently lost — this + ADR is the record). +4. Add a permanent contract test asserting no `codeql-action` reference + exists anywhere in `codeql-pr.yml`. +5. Only then, re-add `.github/workflows/codeql-pr.yml` to ruleset `18156473`'s + required `workflows` list (admin:org PUT, same mechanism used to remove + it) and verify a real PR observes a successful, correctly-named required + check before declaring this ADR's status Accepted. diff --git a/docs/adr/0026-ecosystem-admin-web-sso-and-keyvault.md b/docs/adr/0026-ecosystem-admin-web-sso-and-keyvault.md new file mode 100644 index 0000000000..e2f1f5f998 --- /dev/null +++ b/docs/adr/0026-ecosystem-admin-web-sso-and-keyvault.md @@ -0,0 +1,166 @@ +# ADR-0026: Ecosystem admin-web architecture — Keyverse SSO and Keyvault + +- **Status:** Accepted +- **Date:** 2026-09-02 +- **Scope:** cross-repository admin-web architecture for `noema`, `contextual-orchestrator`, and `keyverse` + +## Context + +The owner asked for admin web UIs across three repositories +(`noema`, `contextual-orchestrator`, `keyverse`) and for mutual +integration so `keyverse` — currently a Keycloak-fronting central Identity +Provider — can also be used as a Keyvault (secrets/credential management, +analogous to Azure Key Vault or HashiCorp Vault), later expanded by the +owner to two further Keyverse capabilities: service-to-service ABAC/RBAC, +and a "login credential store" for service-account/machine credentials. + +Direct repository research (cloned fresh, not assumed) found: + +- **`contextual-orchestrator`** already runs a real, serving `/admin` + operator console (`admin.py`, inline stdlib HTML/JS, eight Figma-grounded + screens) with no per-model LLM timeout control — the exact gap + `docs/product-goal-directive.md` §8 already names. An `admin_ui/` + React+Storybook scaffold exists but is confirmed (by direct inspection, + matching that repo's own planning ADR 0036, superseded) to be the + unmodified Vite demo output — no admin-web work in flight there. This + was the readiest of the three repos: it already had a serving console, + an established KV/audit pattern (`credentials.py`, `model_group` + family), and an explicit product requirement to build against. +- **`keyverse`** had no encrypted secrets store (`kv_store.py`'s + `idp_config_entries` is its own internal, unencrypted config — never a + generic secrets product surface) and no frontend of any kind. PR #103 + (open, Draft) already implements most of the requested service + ABAC/RBAC capability (`authorization_plane.py`, `org_authorization.py`, + ADRs 0010–0012) but is not currently mergeable. +- **`noema`** is a Cloudflare Worker OIDC/credential-exchange broker with + only `/health`, `/ready`, `/exchange` and Durable-Object-only internal + state — no admin-readable HTTP surface exists to build a console on top + of today. The least ready of the three. + +Per this repo's own scoping guidance for genuinely multi-week product +work, the correct first iteration is the smallest real, honestly-scoped +slice per repo — not three parallel half-built admin webs. + +## Decision + +1. **Keyverse is the shared SSO provider for every admin web in this + ecosystem.** It is already the org's central IdP; admins authenticate + to each product's admin console via Keyverse OIDC rather than a + per-repo local admin credential. This is itself the "상호 연계" + (mutual integration) the owner asked for, independent of the Keyvault + question. **Design only in this iteration** — `contextual-orchestrator`'s + `/admin` still uses its existing shared-bearer-token session model + (`/admin/session`); wiring Keyverse OIDC in is the next concrete step + for that console, tracked as an explicit open item rather than + silently deferred. +2. **Each repo's admin web stays a thin frontend over that repo's own + backend API**, not a shared cross-repo frontend package — there is no + second consumer of shared UI primitives yet (matching + `contextual-orchestrator`'s own ADR 0033 reasoning for why Storybook/ + component tooling stays deferred there specifically). +3. **Keyverse's Keyvault is a bounded context separate from its IdP + identity/config modules**, sharing only the KV storage *pattern* + (Protocol + in-memory/SQLite backends) already proven in that repo, + not any shared table. `contextual-orchestrator`'s existing + `CredentialBackend` Protocol (pluggable backends, KV-not-env + discipline) is the natural adapter target for a future + `KeyverseCredentialBackend` — the motivating first consumer, not + implemented in this pass. Full reasoning: `keyverse` ADR-0014. +4. **Service ABAC/RBAC is not rebuilt here.** Keycloak's built-in + Authorization Services (UMA 2.0) exist but are unconfigured in this + deployment and do not natively cover the hierarchical org-path + inheritance CWL's Orgmetra-owned org tree requires; PR #103 already + implements that hierarchy. Recommendation: reconcile and land PR #103 + rather than duplicate it. Full reasoning: `keyverse` ADR-0015. +5. **"Login credential store" is Keyvault plus per-service + Anti-Corruption Layers, not a fourth Keyverse module.** Centralizing + secret *storage* in Keyverse while each consuming service keeps its + own credential-taxonomy knowledge (via its own Protocol adapter, e.g. + `contextual-orchestrator`'s `CredentialBackend`) avoids growing + Keyverse into a service that must change whenever any consumer's + credential schema changes. Full reasoning: `keyverse` ADR-0016. +6. **The first implemented slice is `contextual-orchestrator`'s per-model + LLM timeout admin surface** (view/set/clear/restore, units, priority/ + inheritance, validation, audit history, API contract — the exact §8 + requirement), extending the existing `/admin` console in place per its + own ADR 0033/0042. `keyverse`'s Keyvault (write/read/delete/list APIs, + encryption at rest via Fernet, audit logging) is implemented alongside + it as the second slice, since it was independently ready and directly + answers the Keyvault half of the owner's request. `noema` gets no code + change this iteration — it has no admin-relevant state to expose yet; + the honest next step there is deciding what operational state (OIDC + exchange health/rate, App-token issuance evidence) is worth exposing + before building a console around it. + +## Consequences + +- No repo gained a half-built parallel admin frontend; each shipped + either a real, tested slice or an explicit, evidenced "not yet, and + here is why" record. +- Cross-repo SSO and the Keyvault-as-credential-backend consolidation are + both real, next, concretely-scoped follow-ups — not vague future work — + recorded here and in the two repos' own ADRs so the next iteration does + not have to re-derive this research. +- `keyverse` PR #103 (service authorization) is now more clearly the + blocking dependency for capability #2 of the owner's three-capability + Keyverse request; this ADR does not change its status, only records + that a competing implementation was deliberately not built. + +## Rejected alternatives + +- **Build out `admin_ui/` (React+Storybook) for `contextual-orchestrator` + instead of extending `admin.py`.** Rejected: contradicts that repo's own + operative ADR 0033, and no revisit trigger from that ADR is met by this + work. +- **Build a from-scratch policy engine for Keyverse service ABAC/RBAC.** + Rejected: PR #103 already implements the actual (hierarchical, + org-path-aware) requirement; a second implementation would duplicate + ~2,000 lines of already-written, already-tested domain logic. +- **Centralize per-service credential semantics inside Keyverse.** + Rejected: violates this org's minimal-Shared-Kernel/Anti-Corruption-Layer + DDD convention and would couple Keyverse's deploy cadence to every + consuming service's credential taxonomy. +- **Force a code change into all three repos this iteration regardless of + readiness.** Rejected per this org's own genuinely-multi-week scoping + guidance: `noema` had no admin-relevant surface to build against yet, + and forcing one would have meant fabricating state or shipping a + console with nothing real to show. + +## Update — 2026-09-03: `contextual-orchestrator#1010` closed, not merged + +Decision item 6 above named `contextual-orchestrator#1010` (per-model LLM +timeout admin surface) as this iteration's first implemented slice. That PR +was subsequently **closed unmerged by the repo owner the same day** (2026-09-02, +`closed_at` 05:10:46Z — after this ADR PR was opened at 03:40:12Z), on a +categorical objection independent of this ADR's design: "the current manual +timeout-setting semantics must not become production authority," plus four +distinct unresolved correctness findings in the PR's live-enforcement wiring +(local queue path ignores the override, passthrough/tool requests bypass it, +failed persistence can leave the live timeout mutated, and admin-refresh races +can misreport/stale audit state). A subsequent repair-policy recheck (recorded +on the PR and in `docs/product-technical-gap-baseline.md`) confirmed this +closure is valid under the org's repair-not-close policy's "explicit user +instruction" ground, and that the PR's delta is preserved (not orphaned) on +its own closed branch for selective future reuse once a research-/standard-backed +timeout allocator exists to host it — not revived as-is. + +**This ADR's own architecture decisions (1–5) are unaffected** — they concern +the SSO/Keyvault/ABAC-RBAC/credential-store shape, not the timeout-surface +implementation. Only decision item 6's specific claim that the timeout slice +was "implemented" is now stale. `keyverse#129` (Keyvault, this iteration's +second slice) is unaffected by this and remains open. Left as an update rather +than rewriting the original decision record, so the historical reasoning +trail (what was true when each decision was made) stays intact. + +## References + +- `contextual-orchestrator` planning ADR 0033 (admin console UI tooling + boundary), 0036 (superseded React/Storybook proposal), 0042 (per-model + timeout admin surface — this iteration's `contextual-orchestrator` + slice, subsequently closed unmerged; see Update above). +- `keyverse` ADR-0014 (Keyvault bounded context), ADR-0015 (service + authorization plane), ADR-0016 (login credential store). +- `docs/product-technical-gap-baseline.md`, 2026-09-02 entry (repair-policy + recheck of `contextual-orchestrator#1010`'s closure). +- `docs/product-goal-directive.md` §8 (LLM/orchestration; the per-model + timeout admin requirement this ADR's first slice attempted to close). diff --git a/docs/adr/0027-code-scanning-required-workflow-audit.md b/docs/adr/0027-code-scanning-required-workflow-audit.md new file mode 100644 index 0000000000..a26266b9bc --- /dev/null +++ b/docs/adr/0027-code-scanning-required-workflow-audit.md @@ -0,0 +1,82 @@ +# ADR-0027: Audit all organization-required code-scanning workflows + +- **Status:** Proposed +- **Date:** 2026-09-02 +- **Scope:** organization ruleset `18156473`, `scripts/ci/audit_central_required_workflows.py`, and its executable ruleset contracts + +## Problem + +Organization ruleset `18156473` was expanded on 2026-09-02 to require the central CodeQL, Scorecard, and OSV PR workflows in addition to the original seven required workflows. The protected-main audit source still enumerated only those original seven paths. As a result, the scheduled governance audit could report success even if one or all of the newly required code-scanning workflows disappeared from the live ruleset. + +The defect is a control-plane single-writer mismatch: live policy changed but its canonical executable audit contract did not change with it. Documentation alone cannot close that gap. + +## Constraints + +1. The audit remains fail closed: every required workflow path must be present exactly once and sourced from `ContextualWisdomLab/.github@refs/heads/main`. +2. Existing repository-scope, pull-request review, deletion, non-fast-forward, and stacked-PR checks remain unchanged. +3. The three workflow files already exist in the canonical repository; this decision does not copy workflow source into consumers. +4. No mutable branch or PR head becomes consumer release authority. Live ruleset source ref remains `refs/heads/main` and protected-main history remains the production authority. +5. The PR remains Draft/Proposed until exact-current-head required Checks, security evidence, and independent reviews are terminal and clean. + +## Alternatives + +### Keep the audit at seven paths and rely on rollout documentation + +Rejected. The original incident was caused by documentation and live policy diverging. A prose-only control repeats the same failure mode. + +### Add a separate optional code-scanning audit + +Rejected. These workflows are already part of the same active organization required-workflow rule. Optional or separately invoked validation would allow the canonical audit to pass while security-policy drift exists. + +### Audit all ten paths in the existing canonical contract + +Selected. The existing audit already validates path uniqueness, source repository, and source ref. Extending its required path set reuses the established fail-closed mechanism and makes future drift observable. + +## Decision + +`REQUIRED_WORKFLOW_PATHS` contains all ten organization-required paths, including: + +- `.github/workflows/codeql-pr.yml` +- `.github/workflows/osv-scanner-pr.yml` +- `.github/workflows/scorecard-pr.yml` + +The main ruleset fixture is derived from that canonical tuple so tests cannot silently preserve a second seven-path policy. Structural-drift expectations and rollout-document assertions are extended to the three code-scanning paths. + +## Test-first evidence + +- RED/current-main reconciliation: `3608fbee43da40d91dadda6afaa8881aacd450c3`. Its new regression requires all three code-scanning paths while the exact source at that commit still contains only seven paths. +- Production repair: `3501ac32cbec682a77fbc0b79ff51cb33a7adbde`. Its audit source contains all ten paths and its existing ruleset fixture derives directly from `REQUIRED_WORKFLOW_PATHS`. +- The RED commit is a two-parent, non-force reconciliation of PR #1719 and protected `main@b4eec000d21084accb736d289eb64cfd78e7a91a`; concurrent control-plane work is preserved rather than rebased away. + +Hosted exact-current-head evidence and independent review remain required before this ADR may become Accepted. + +## Consequences and follow-up + +A future removal of CodeQL, Scorecard, or OSV from ruleset `18156473` becomes a deterministic governance failure instead of a silent loss of coverage. The rollout document's historical “audit tool coverage” follow-up text must be reconciled with this source repair before merge so the repository has one current statement of policy. + +## Update — 2026-09-03: `codeql-pr.yml` removed from the ruleset; the final tuple has nine paths, not ten + +The "Decision" and "Test-first evidence" sections above describe this PR's own mid-flight state, when +`codeql-pr.yml` was still expected to be one of the three newly-required code-scanning workflows. Later +the same day, ruleset `18156473` was updated to **remove** `.github/workflows/codeql-pr.yml` from its +required `workflows` list: every ruleset-injected run of that workflow, across all ~71 covered +repositories, concluded `startup_failure` with zero check runs ever created -- `github/codeql-action/init` +and `github/codeql-action/analyze` are categorically disallowed inside a ruleset-required workflow, a +GitHub platform restriction, not a defect in the workflow file's own content. See +`docs/org-required-workflow-rollout.md`'s "Audit tool coverage" section and the 2026-09-03 12:20 KST +evidence entry for the full removal record, and `docs/doctoring/codeql-pr-required-workflow-always-fails.md` +for the platform-restriction root cause. + +**The actual, final `REQUIRED_WORKFLOW_PATHS` therefore contains nine paths, not ten** -- +`.github/workflows/scorecard-pr.yml` and `.github/workflows/osv-scanner-pr.yml` are included exactly as +decided above, but `.github/workflows/codeql-pr.yml` is deliberately excluded and must stay excluded; +re-adding it to this tuple would silently reintroduce the 100% `startup_failure` regression the removal +fixed. `tests/test_code_scanning_required_workflow_contract.py::test_ruleset_audit_deliberately_excludes_codeql_pr` +is the permanent regression guard for this. Left as an "Update" rather than rewriting the sections above, +so the historical record of what this PR's own RED/GREEN commits contained at each point stays intact. + +## References + +GitHub. (n.d.). *REST API endpoints for rules*. GitHub Docs. https://docs.github.com/rest/repos/rules + +GitHub. (n.d.). *Available rules for rulesets*. GitHub Docs. https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets diff --git a/docs/adr/0029-sidecar-preflight-lazy-fill.md b/docs/adr/0029-sidecar-preflight-lazy-fill.md new file mode 100644 index 0000000000..166d49f9a8 --- /dev/null +++ b/docs/adr/0029-sidecar-preflight-lazy-fill.md @@ -0,0 +1,83 @@ +# ADR-0029: Review sidecar preflight fills the served set lazily to a readiness target + +- **Status:** Proposed +- **Date:** 2026-09-06 +- **Scope:** `scripts/ci/contextual_orchestrator_review_launcher.py` (`_preflight_review_agents`, the stage limits), `scripts/ci/contextual_orchestrator_review_sidecar.sh` (`ORCHESTRATOR_CATALOG_LIMIT` default), ADR-0003 §2's stage budget sentence +- **Amends:** ADR-0003 (the "twelve-route startup budget" clause). ADR-0005's attempt counts are historical and are not restored. + +## Problem + +The review sidecar selected a fixed catalog of twelve routes and probed every one of them, then served whatever was ready. `.github#1939` made the selection diverse (round-robin across credential accounts inside each cost/ZDR tier, four routes per account), which was right, but it exposed a second defect: the per-account slice is filled from an alphabetically sorted model list, and for both NVIDIA NIM keys the first four models are `deepseek-v4-flash`, `deepseek-v4-pro`, `gemma-3-12b`, `gemma-3-4b`. NIM lists the two `gemma-3` models but answers `404` to every chat request on every run observed. Each NVIDIA key therefore served two working routes, both the most contended models, while the pre-#1939 eight-slot fill had reached `meta/llama-3.2-11b`, `llama-3.2-90b` and `meta/muse-glimmer-30b`, which were ready in every Strix artifact of that afternoon. + +Measured on `ContextualWisdomLab/.github` (lane jan's census on `#1948`, verdict-step conclusions only, draft skips excluded): + +| window | preflight ready of 12 | `noema-review` success / failure | +|---|---|---| +| before `#1939` (`main@f2f91b80`, 2026-09-05T17:25Z) | 6, 6, 5 (16:37–16:56Z artifacts) | 7 / 14 | +| after | 1–3 (23:47Z onward) | 0 / 22 | + +The evening's rate-limit pressure is a confound; the mechanism is not. A fixed slice from a list with dead entries wastes the slice, and probing every candidate regardless of how many are already ready spends per-key rate budget (`#1948`) for nothing. + +## Constraints + +1. No model name is hard-coded anywhere in the fill; a dead candidate is discovered by its probe, not by a list. +2. Probe spend per sidecar boot stays bounded and is stated as a number, because the probes themselves consume the per-key budgets the served routes need (`#1948`). +3. `#1947`'s deferral (a probed route that answered a transient status is kept behind the ready routes) applies unchanged to whatever was probed. +4. ADR-0003's evidence-triggered priced fallback (only after every free candidate rejects) keeps its shape; the two stages still share one startup budget. +5. `ready_count` keeps its meaning (routes proven ready by a probe) so the peers' post-merge discriminators stay comparable. + +## Decision + +The catalog is a **candidate list**, not the served set. `build_zdr_prioritized_catalog` keeps its tier-then-round-robin order (`#1939`) and is asked for up to `REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24` candidates (per-account cap unchanged at 8; the sidecar's `ORCHESTRATOR_CATALOG_LIMIT` default rises from 12 to 24). `_preflight_review_agents` probes candidates **in that order and stops** as soon as `REVIEW_PREFLIGHT_TARGET_READY = 8` routes are ready or `REVIEW_PREFLIGHT_MAX_PROBES = 16` probes have been spent, whichever comes first. The auto pool's split becomes 16 free candidates and up to 8 priced fallback candidates; the production `free` pool (the sidecar default; it has no fallback stage) lists all 24. A silent candidate's probe costs up to one transport timeout (one artifact spent 805 s on 19 probes), so the probe cap bounds preflight wall time as well as request count. + +**Account skip.** *(The "skipped without a probe" and "two probes per account" claims in this paragraph are superseded by the 2026-09-06 amendment below: such a candidate is postponed, and the leftover budget is spent on it.)* A 429 at preflight is a per-key answer, not a per-model one. Once one credential account has answered 429 to `REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429 = 2` consecutive probes, its remaining candidates are skipped without a probe and the walk continues with the other accounts' next candidates; the two probed routes are still deferred. Under the real 2026-09-06 candidate order (lane jan's table on `#1949`, rebuilt from `#1938`'s Strix artifact: both NVIDIA keys list deepseek ×2, gemma-3 ×2 (404), gemma-4-31b (empty), then the llama and muse routes; every OpenRouter free route answers 429) the plain sixteen-probe walk yields about five ready and five deferred and the readiness target is unreachable, because five probes go to an account whose every route had answered 429 in every artifact since 21:00Z and four to the dead gemma-3 entries. With the skip, the same sixteen probes reach both keys' `llama-3.2` routes and the target of eight. This is why the free pool lists 24 candidates while probing at most 16: the tail is reachable exactly when an account is skipped, and the report separates `skipped_count` from the unreached remainder (`candidate_count − probed_count − skipped_count`). A rate-limited hour therefore costs two probes per account instead of the full budget. + +The sidecar's job-log echo of the preflight JSON (`sed -n '1,400p'`, previously 160 lines) now fits 16 probed routes; the artifact copy was always complete. + +The report gains `candidate_count`, `target_ready` and `probe_budget`; `probed_count` now counts probes actually sent, and `rejected_count` is `probed − ready − deferred`. Unprobed candidates get no `routes` row. + +## Consequences + +- **Good:** a dead candidate costs one probe and yields its place to the next candidate in the same account's list; a healthy hour stops after about eight to twelve probes instead of always twelve; a bad hour is bounded at sixteen probes per stage. +- **Cost:** in an hour where nothing is ready the sidecar sends up to 16 probes per stage where it sent 12, a third more against already exhausted keys. This is the price of finding routes past the dead ones; `#1948`'s shared rate ledger is the lever above it. The cap is also a wall-time bound: a 16-token probe can hold the full 90 s receive timeout (`#1661` run 34008191123, 04:48Z, both NVIDIA keys' deepseek-v4-pro probes at 90.06 s and 90.10 s), so a fully silent hour costs at most 16 × 90 s = 24 minutes of preflight against 18 today, and the account-skip rule cuts a rate-limited hour to two probes per account. *(That last clause is superseded by the 2026-09-06 amendment: a rate-limited hour now spends the whole probe budget rather than two probes per account.)* +- **Unchanged:** a route that answers the probe and then goes silent at request time still costs the gateway's full retry budget (`contextual-orchestrator#1045`); readiness is measured at 16 tokens (`#1454`). +- **Discriminator:** post-merge, `probed_count` versus `candidate_count` per boot and `ready_count` of the served set, read from the `runtime preflight summary` in the job log or the `noema-sidecar-evidence` artifact, compared with the table above. + +## Alternatives considered + +- **Raise the per-account cap back to 8 with a 12-route limit** — restores the pre-#1939 pool but reintroduces the single-account fill that `#1939` fixed; the 404s would still occupy slots. +- **Exclude models that 404 by name** — a hard-coded exclusion list the next discovery change silently invalidates; rejected by constraint 1. The discovery-side question (why NIM lists models it does not serve) remains open in `contextual-orchestrator`. +- **Family-level interleave inside each account's list before the cap** (jan's second layer) — would make each NVIDIA key's first six candidates span deepseek, gemma, llama, muse, minimax, mistral, but it needs a model-family equivalence derived from names, which ADR-0003/#1468 deliberately avoid; kept in reserve if the post-merge census shows same-family contention as the residual after the account skip. +- **Probe all 24 candidates** — best served set, double the probe spend in the hour that can least afford it; rejected by constraint 2. + +## Amendment 2026-09-06: a set-aside candidate is postponed, not banned + +**Evidence.** Sixteen sidecar artifacts were collected on 2026-09-06 across `.github`, `argos`, `bandscope` and `naruon`; **fourteen** ran the merged rule (two, `argos` 34013128112 and `bandscope` 34013146167, still carry the pre-`#1949` report shape and are excluded). The fourteen fall into three classes, not two. + +| class | boots | `probed / skipped / ready` | second pass? | outcome | +|---|---|---|---|---| +| budget spent in the first pass | 8 | 16 / 4 / 5–6 | no — budget already gone | served; the sixth ready route (`llama-3.2-11b` on the second NVIDIA key, catalog position 17, ready in exactly these 8 artifacts) is reached **only** because four OpenRouter probes were set aside — the benefit the rule was designed for | +| candidates exhausted, budget left | 1 | 12 / 12 / 3 (`argos` 34014143870, 06:56Z) | **yes**, up to 4 probes | served with 5 deferred, but the target of 8 was unmet with 4 probes unspent | +| every account set aside | 5 | 6 / 18 / 0 (`rejected 6`, all 429) | **yes**, up to 10 probes | preflight failed closed | + +So the change is not confined to bursts: one served, ordinary-minute boot also ends its first pass under target with budget in hand. Only a boot that spends all sixteen probes in the first pass is untouched. + +The sidecar stderr of `.github` run 34016207820 shows its six probes (both NVIDIA keys' two deepseek routes, two OpenRouter routes) refused 429 between 07:49:35.111Z and 07:49:35.767Z. Because the walk is a round-robin across three accounts, "two consecutive 429s" on one account is two requests about **310 ms** apart (`nvidia_nim` at .111 and .422), not two probes a tenth of a second apart. The rule set all three accounts aside, the walk ended **with ten of its sixteen probes unspent**, and because deferral requires one ready route (`#1947`) nothing was served either. The five boots of that class span 07:24:50Z to 08:04:41Z. + +A refusal is not a verdict on the account. Run 34016093772 was inside its *own* preflight while that burst happened (its probes run from 07:46:21Z), and its `llama-3.2-11b` probes on the **same two NVIDIA keys** answered ready at 07:50:58.7Z and 07:50:59.0Z — 84 seconds after those keys refused 429 at 07:49:35Z. That boot ended `probed 16 / ready 5`. + +What is **not** measured: whether the ten unspent probes would have found a ready route *inside* the burst itself. No artifact answers it, because nothing records how long a refusal lasts — hence `retry_after_s` below. The pre-`#1949` walk failed similar windows for a different reason (`.github` runs 34006939646 / 34008191123 / 34008575125, 04:24–05:11Z: the same six 429s, then six gemma 404s, `ready 0` at `probed 12`), so the ban is not a regression this amendment invents; it is the ban meeting a 24-candidate list whose tail it can no longer reach. + +**Decision.** A candidate set aside by the account rule is appended to a postponed list in catalog order. Once the first pass ends with the readiness target unmet and probe budget left, the postponed candidates are probed in that order until the budget is spent; no account rule applies in that second pass. A boot that spends all sixteen probes in the first pass is unchanged; the other two classes above gain a second pass. The justification is not that the second pass rescues a burst — that is unmeasured — but that ending a walk under target with probe budget in hand is indefensible when the catalog's tail is where the ready routes live. Constraint 2 holds unchanged: at most sixteen probes per stage, and a silent second-pass probe is bounded by that count, not by a clock (ADR-0003 admits no time rule here). + +**Cost.** The second pass spends probes the walk used to abandon, so it lengthens the boot it rescues and the boot it does not. A refused probe costs about 120 ms. A **silent** one costs up to the full 90 s receive timeout (`#1661` run 34008191123, both NVIDIA keys' `deepseek-v4-pro` probes at 90.06 s and 90.10 s), and the postponed tail is full of them: `google/gemma-4-31b-it` answered `TimeoutError` in 15 of the 19 probes that reached it across these artifacts. The measured burst is therefore not a 1.2-second case — replaying 34016207820's catalog, its second pass would reach both `gemma-4-31b-it` entries, so about 3 minutes — and the worst case is 10 × 90 s ≈ **15 minutes** added to a boot that will still fail, taking a dead window from about 4 minutes to about 19 and holding the runner slot for it. + +**The two-stage path costs more than the free pool's figure.** Whenever a stage lists no more candidates than the probe budget — which is exactly the auto split, 16 free primary and 8 priced fallback — the account rule now saves nothing there, because the second pass re-probes everything it set aside. Measured on a two-account, all-429 auto run: `origin/main` sends 8 requests (4 primary, 4 priced), this design sends 24 (16 primary, 8 priced). The priced stage spends paid credit, so it doubles from 4 probes to 8 in a rate-limited hour. That is accepted for the same reason as the free pool — the priced stage only runs after every free route rejected, and stopping it half-probed is the same defect one layer down — but it is a real, stated cost, not a side effect. + +Two things are deliberately **not** traded away. The second pass never draws on the shared escalation budget (`REVIEW_PREFLIGHT_MAX_ESCALATIONS`, one counter for the whole run, carried into the priced stage by `#1458`): a postponed candidate that answers with the budget-too-small signature is rejected as `escalation_reserved_for_first_pass` rather than escalating, because otherwise candidates the previous design never probed would take escalations from the priced stage that had them, and a two-stage run measurably stops serving a route it used to serve. + +That competes directly with the org's 60-job ceiling work, and `#1949`'s measured benefit ("a dead window fails closed in about 4 minutes and returns the slot") is partly traded back for the chance to reach the catalog tail. It stays inside the probe budget this ADR bounds, `postponed_probed_count` plus the provisioning step's duration make the trade visible per boot, and `REVIEW_PREFLIGHT_MAX_PROBES` is the lever if the census says the exchange is bad. + +The report adds `postponed_probed_count`; `skipped_count` now means "postponed and never reached", and `candidate_count − probed_count − skipped_count` keeps its meaning. A refused probe additionally records `retry_after_s` when the response carried a whole-seconds `Retry-After` header (the HTTP-date form and out-of-range values record nothing). Nothing waits on that value; it exists so the next census can answer the question this amendment could not. + +**Discriminator.** `postponed_probed_count > 0` marks any boot that reached a second pass, which includes the `12 / 12 / 3` class as well as the burst class. To isolate the all-429 class, read the first `probed_count − postponed_probed_count` rows of `routes` (they are in probe order) and require every one to carry `http_status` 429. The next census asks (a) whether such boots end with `ready_count ≥ 1`, (b) what fraction of 429 rows carry `retry_after_s` and how long the refusals claim to last, (c) whether the healthy-minute figures (`ready 5–6`) are unchanged, and (d) the provisioning step's duration on those boots, so the benefit in (a) and the cost above are read from one table. If (a) is consistently 0 **and** (b) shows providers publishing a usable delay, the follow-up is to spend the second pass after that delay rather than immediately — a decision this ADR deliberately leaves to that data. `#1948`'s shared rate ledger remains the lever above all of it. diff --git a/docs/automation/hourly-review-repair.md b/docs/automation/hourly-review-repair.md index c7ad21bd1d..d61e47cf8d 100644 --- a/docs/automation/hourly-review-repair.md +++ b/docs/automation/hourly-review-repair.md @@ -43,14 +43,21 @@ parameters to the reusable scheduler: ```yaml target_repository: ContextualWisdomLab/clearfolio base_branch: main -max_prs: "50" +max_prs: "200" max_dispatches: "1" +scan_window_size: "50" +rotation_seed: github.run_number retry_hours: "1" ``` -The scheduled heartbeat is `23 * * * *`. Repository-scoped concurrency and -`cancel-in-progress: true` ensure that a superseded Clearfolio queue scan does -not overlap its successor. At most one repair dispatch is created per run. +The scheduled heartbeat is `23 * * * *` with non-cancelling, repository-scoped +concurrency (`cancel-in-progress: false`): a still-running Clearfolio queue +scan is never preempted by the next heartbeat's dispatch, which instead +queues behind it in the same `clearfolio-hourly-review-repair` group. At most +one repair dispatch is created per run. +The run number rotates across the discovered queue in 50-PR windows. Only the +selected window receives paginated review/check and comment inspection, and +inspection stops immediately after the single dispatch budget is consumed. The caller passes only the established `PR_REVIEW_MERGE_TOKEN` and `OPENCODE_APPROVE_TOKEN` scheduler credentials. It does not receive or forward any of the five @@ -65,8 +72,10 @@ The Orgmetra caller provides the following immutable operating parameters: ```yaml target_repository: ContextualWisdomLab/Orgmetra base_branch: develop -max_prs: "50" +max_prs: "200" max_dispatches: "1" +scan_window_size: "50" +rotation_seed: github.run_number retry_hours: "2" ``` diff --git a/docs/doctoring/actions-plan-concurrency-ceiling-20260903.md b/docs/doctoring/actions-plan-concurrency-ceiling-20260903.md new file mode 100644 index 0000000000..39796beb44 --- /dev/null +++ b/docs/doctoring/actions-plan-concurrency-ceiling-20260903.md @@ -0,0 +1,114 @@ +# Doctoring record: the org's GitHub Actions concurrency ceiling is a plan-level quota, not a workflow defect (2026-09-03) + +- **Date:** 2026-09-03 +- **Subject:** two peer sessions independently observed the org's GitHub Actions run queue growing rather + than shrinking this week and, in that tick, proposed auditing/consolidating/centralizing workflow files + across the org as the fix. Before either session sank time into that plan, this root cause needed a + durable record: the actual bottleneck this session identified is a **plan-level concurrent-job quota**, + not workflow duplication, and consolidating workflow files cannot lift it. +- **Decision record:** none in `docs/adr/` — this is a diagnostic/root-cause finding for the org owner's + awareness and eventual plan-tier decision, not an architecture decision this repository can make. +- **PR:** see the PR that carries this commit. + +## Primary evidence + +The user directly reported, and shared a screenshot of, the organization's GitHub Actions usage view +earlier in this session showing **58-60 of a 60 concurrent-job plan limit in use**. That is the primary +source for the specific ceiling figure in this record. The raw screenshot itself is not reproducible from +this doc (it was shared inline in conversation, not committed to the repository), so the number here is +reported as the user stated it, not independently re-derived pixel-for-pixel — flagged explicitly so a +reader can tell primary-source-observed-directly-by-the-user apart from what this session could verify +itself via the API (below). GitHub does not expose an org's concurrent-job plan ceiling through the +standard REST API available to this session (it is a billing/plan-settings value, visible only in the +org's own Settings → Actions/Billing UI) — confirming the exact number and its precise scope (whether it +counts standard-runner jobs only, whether larger/self-hosted runners have a separate pool, which plan tier +the org is on) requires the org owner to check that page directly; this record does not claim to have +re-verified those specifics independently. + +## Corroborating evidence (live, reproducible, gathered for this record) + +A live sample taken 2026-09-03 across three of the org's most CI-active repositories, using: + +```bash +gh api "repos/ContextualWisdomLab//actions/runs?status=in_progress&per_page=1" --jq '.total_count' +gh api "repos/ContextualWisdomLab//actions/runs?status=queued&per_page=1" --jq '.total_count' +``` + +| Repository | `in_progress` | `queued` | +|---|---|---| +| `.github` | 5 | 1,877 | +| `contextual-orchestrator` | 0 | 727 | +| `naruon` | 5 | 416 | +| **Total (3-repo sample)** | **10** | **3,020** | + +This is a deliberately small sample, not a full 63-repo census — an attempted full sweep across every +non-archived, non-fork repository (the same corpus as the 2026-09-02 workflow-duplication audit) hung +indefinitely on this run and was aborted; a post-hoc `gh api rate_limit` check immediately after showed +5,000/5,000 REST calls remaining, so the hang was not caused by hitting the org's shared REST rate limit +(consistent with this session's standing practice of preferring REST over GraphQL to avoid that limit) — +its actual cause is undetermined and not investigated further here, since the 3-repo sample already +establishes the pattern this record needs. + +The pattern itself is the useful signal: single-digit `in_progress` counts (5, 0, 5) against +quadruple-digit `queued` counts (1,877; 727; 416) in the same moment, across independently-owned +repositories, each triggering its own workflows on its own schedule. That shape — many jobs queued, +very few ever concurrently running — is exactly what a hard, roughly-constant, **org-wide** (not +per-repository) concurrent-job ceiling produces, and is hard to explain by per-repository causes alone +(each repository's own workflow volume, trigger frequency, and CI design differ substantially). It is +consistent with, though does not by itself prove, the specific 58-60/60 figure from the primary evidence +above. + +## Relationship to other queue-related findings already in this repository + +This is not the first queue-depth observation recorded here, and this finding does not supersede or +contradict the earlier ones — they describe different, plausibly-compounding causes: + +- `docs/product-technical-gap-baseline.md`'s 2026-08-31 entry (chained required-workflow poller removal) + cites "53 concurrent Actions runs and a growing runner queue" as the trigger for removing roughly eleven + runner-hours of polling per PR — a real, already-fixed contributor to total load, but framed as a + mechanism-level fix (reduce runner-hours consumed per PR), not a claim about the plan's own ceiling. +- The later `ubuntu-latest` starved-floating-image finding (same file, referencing 822 queued Actions runs + observed at merge time) diagnosed a *scheduling* problem — GitHub-hosted runners requesting the floating + `ubuntu-latest` label sitting `queued` with no runner assignment for hours even when capacity should have + been available, fixed by pinning off the floating label. That is a distinct failure mode from a hard + concurrency quota: a starved image can leave slots idle *despite* available capacity, whereas a plan + ceiling caps how many jobs can ever run concurrently even with perfect scheduling. Both can be true at + once and both can slow the same queue; neither finding invalidates the other. +- A separate, still-unmerged-as-of-this-writing finding (`project_strix_concurrency_starvation_unfixed` in + this session's own working notes) identifies that `strix.yml`'s concurrency group is scoped per-repository + rather than per-PR, which starves cross-PR Strix evidence specifically — again a distinct, compounding + mechanism, not the same thing as the org-wide plan ceiling this record documents. + +## Implication for workflow-consolidation proposals + +Consolidating or centralizing workflow files — the idea both peer sessions were independently converging +on this tick as *the* fix for the growing queue — is real hygiene and can reduce the *total number of +runs triggered* (fewer redundant CI paths competing for the same slots), which helps the queue drain +somewhat faster once jobs are submitted. It does **not** change how many jobs GitHub will run concurrently +for this organization at once: that number is set by the plan tier, not by how many `.yml` files exist or +how many of them are centralized versus per-repository. A large cross-repo consolidation-and-deletion +effort undertaken on the theory that it would resolve the backlog would be solving the wrong layer of the +problem, at real cost (each deletion needs branch-protection `required_status_checks` re-verified per +repo, and any repo-specific `with:` tuning preserved or intentionally dropped). + +## Recommendation + +This is a plan/billing decision, not a code change either agent session can make: raising the concurrent-job +ceiling (a higher GitHub plan tier, purchasing additional included concurrency, or provisioning +self-hosted/larger runners with their own separate capacity pool) is the org owner's call to make with the +actual billing page in front of them, not something to infer further from repository-side evidence. +Workflow consolidation remains worth pursuing for its own, independent hygiene reasons (see +`docs/doctoring/ci-workflow-duplication-audit-20260902.md` for what is and is not already duplicated +org-wide) — but should not be scoped or prioritized as *the* fix for the current backlog growth. + +## Audit trail + +- User-reported screenshot of the organization's Actions usage view, shared earlier in this session + (primary source for the 58-60/60 figure; not independently re-verifiable from this record alone). +- Live `gh api` sample gathered 2026-09-03 for this record (table above); `gh api rate_limit` confirmed + 5,000/5,000 REST calls remaining immediately after the aborted full-org sweep, ruling out rate-limiting + as the sweep's failure cause. +- `docs/product-technical-gap-baseline.md` — the 2026-08-31 chained-poller-removal entry and the + `ubuntu-latest` starved-image entry, both cross-referenced above. +- `docs/doctoring/ci-workflow-duplication-audit-20260902.md` — the org-wide workflow-duplication sweep this + record's "Implication" section points back to. diff --git a/docs/doctoring/actions-queue-saturation-hourly-sweep.md b/docs/doctoring/actions-queue-saturation-hourly-sweep.md index a0d3122290..c68f91d34c 100644 --- a/docs/doctoring/actions-queue-saturation-hourly-sweep.md +++ b/docs/doctoring/actions-queue-saturation-hourly-sweep.md @@ -19,7 +19,7 @@ The production change must also update `docs/org-required-workflow-rollout.md` s ## Safety boundary -This repair does not mark queued checks successful, cancel the sole current-head evidence, weaken required workflows, relax approval requirements, or synthesize review state. Queue hygiene remains fail-closed. Cross-repository mutation credentials, exact-head validation, stale-head cancellation rules, unavailable-repository thresholds, scheduler concurrency groups, and merge guards remain unchanged. +This repair does not mark queued checks successful, cancel the sole current-head evidence, weaken required workflows, relax approval requirements, or synthesize review state. A later 2026-09-04 ownership repair removed cross-repository Actions-run cancellation from this sweep; native per-PR concurrency and the local exact-head coalescer now own supersession. Cross-repository mutation credentials, unavailable-repository thresholds, scheduler concurrency groups, and merge guards remain unchanged. No organization-owned identifier introduced by this repair uses an ambiguous single-word domain name. GitHub event fields and cron syntax are externally mandated contract terms and remain unchanged except for the cadence value. diff --git a/docs/doctoring/agent-review-runtime-quality-workflow-consolidation-20260903.md b/docs/doctoring/agent-review-runtime-quality-workflow-consolidation-20260903.md new file mode 100644 index 0000000000..bbba1edafc --- /dev/null +++ b/docs/doctoring/agent-review-runtime-quality-workflow-consolidation-20260903.md @@ -0,0 +1,88 @@ +# Agent 리뷰 런타임 품질 Workflow 통폐합 + +- 기준 저장소: `ContextualWisdomLab/.github` +- 구현 기준: `main@232107a0b6235efaa4a221a41443c436eac3dd00` +- 확인 시점: 2026-09-03 KST +- 상태: 구현 및 exact-head 검증 대상 + +## 문제 + +다음 세 Workflow는 서로 다른 계약을 검증하지만 동일한 Pull Request에서 각각 +Workflow run과 runner job을 생성했다. + +- `noema-token-lifetime-quality-ci.yml` +- `opencode-rust-coverage-toolchain-quality-ci.yml` +- `strix-changed-path-quality-ci.yml` + +세 파일은 각자 checkout, Python 준비, dependency 설치를 반복했다. 특히 +`CHANGELOG.md` 변경은 세 Workflow 모두의 path trigger에 포함되어 있어, 제품 코드와 +무관한 공통 변경 한 번으로 세 개의 별도 실행이 생성됐다. Strix 전용 품질 Workflow는 +선언된 Strix 계약 파일보다 훨씬 넓은 `tests` 전체를 실행해 path-gated 검증의 책임 +경계도 흐렸다. + +2026-09-03에 `.github` 저장소에서만 queued run 1,544개를 다시 확인했다. 이 상태에서 +독립적인 품질 Workflow 부팅을 계속 추가하는 것은 60-job ceiling과 대기열 적체를 +악화시키는 구조적 원인이다. + +## 선택 + +세 실행 책임을 `agent-review-runtime-quality-ci.yml`의 단일 Pull Request Workflow와 +단일 runner job으로 통합한다. + +1. concurrency group은 + `agent-review-runtime-quality-{repository}-{PR번호}`로 고정한다. +2. `cancel-in-progress: true`로 같은 저장소·같은 PR·같은 Workflow의 구형 실행만 + 취소한다. +3. checkout과 Python 준비는 각각 한 번만 수행한다. +4. `git diff --name-only base...head`로 Noema, OpenCode, Strix 계약 집합을 선택한다. +5. 공통 Workflow 또는 `CHANGELOG.md`가 바뀌면 세 집합을 모두 검증하되 하나의 + runner에서 순차 실행한다. +6. Strix는 trigger에 열거된 현실적인 계약 테스트와 shell regression만 실행한다. + 저장소 전체 `tests` 재실행은 일반 통합 CI 책임으로 남긴다. +7. runner를 붙잡는 `sleep`, GitHub API polling, `workflow_dispatch`를 두지 않는다. +8. 세 기존 Workflow 파일은 successor가 테스트·path·supply-chain 계약을 완전히 + 승계한 같은 commit에서 삭제한다. + +## 보존한 계약 + +- Noema: 장시간 리뷰 중 installation token 재발급, two-phase handoff, stale-run + cancellation 계약 +- OpenCode: 격리 Rust coverage image의 LLVM 19 경로와 dispatch blob exact hash 계약 +- Strix: docs-only admission, 변경 경로, ModelBehaviorError, NVIDIA NIM fallback, + dependency hash, timeout fixture, shell quick-gate 계약 +- 공급망: pin된 checkout/setup-python/harden-runner와 hash-verified Python dependency +- exact head: checkout SHA와 `github.event.pull_request.head.sha` 일치 검증 + +## 검증 + +새 회귀 계약 `tests/test_agent_review_runtime_quality_consolidation.py`는 다음을 실패 +조건으로 고정한다. + +- 삭제 대상 Workflow 중 하나라도 남음 +- runner, checkout 또는 Python setup이 둘 이상임 +- group에 Workflow·repository·PR 번호 중 하나가 없음 +- `cancel-in-progress: true`가 없음 +- `sleep`, `gh api`, `workflow_dispatch`가 다시 도입됨 +- Noema, OpenCode, Strix의 승계 대상 테스트가 누락됨 +- exact-head 검증보다 먼저 suite가 실행됨 + +격리된 임시 repository 구조에서 이 계약 5개를 실행해 `5 passed`를 확인했다. +GitHub의 current-head checks는 queued 상태를 성공으로 간주하지 않으며, 병합 뒤 +보호된 `main`에서 파일 삭제와 새 Workflow 구문을 다시 확인한다. + +## 운영 효과와 측정 + +공통 경로 변경 기준으로 Workflow run 수는 3개에서 1개로, runner job 수는 3개에서 +1개로 줄어든다. checkout·Python setup도 각각 3회에서 1회로 줄어든다. 이는 해당 +품질 lane의 부팅 수를 66.7% 줄이는 변화다. + +전체 41개 요구의 진척률은 별도 project ledger에서 계속 계산하며, 이 변경 하나만으로 +60-job ceiling 전체가 해소됐다고 주장하지 않는다. 다음 우선순위는 Required OpenCode, +Noema, Strix 본 실행의 current-head admission과 `cancel-in-progress: true`, 그리고 +scheduler wake-up coalescing이다. + +## Rollback + +문제가 확인되면 이 merge commit을 revert하여 세 predecessor Workflow와 기존 테스트 +경로를 함께 복원한다. successor 파일만 삭제하거나 predecessor 일부만 복구해 검증 +공백 또는 중복 trigger를 만들지 않는다. diff --git a/docs/doctoring/code-scanning-required-workflow-audit.md b/docs/doctoring/code-scanning-required-workflow-audit.md new file mode 100644 index 0000000000..66000cae1b --- /dev/null +++ b/docs/doctoring/code-scanning-required-workflow-audit.md @@ -0,0 +1,70 @@ +# Code-scanning required-workflow audit repair + +## Incident + +PR #1719 corrected the rollout record after live organization policy and the repository documentation diverged. The same evidence showed a second owner defect: after ruleset `18156473` gained central CodeQL, Scorecard, and OSV required workflows, `scripts/ci/audit_central_required_workflows.py` still treated only the older seven workflows as authoritative. A future regression of any code-scanning member could therefore escape the scheduled audit. + +## Test-first repair + +The repair is deliberately split so the behavior change has a genuine RED predecessor. + +### RED — `3608fbee43da40d91dadda6afaa8881aacd450c3` + +A new executable contract requires these paths to be members of `audit.REQUIRED_WORKFLOW_PATHS`: + +- `.github/workflows/codeql-pr.yml` +- `.github/workflows/osv-scanner-pr.yml` +- `.github/workflows/scorecard-pr.yml` + +At the same exact commit, the production tuple still contains only the original seven paths. The regression therefore fails for the intended missing-policy reason rather than an environment/setup failure. That commit also reconciles PR #1719 with protected `main@b4eec000d21084accb736d289eb64cfd78e7a91a` using two parents and a non-force ref update. + +### GREEN source — `3501ac32cbec682a77fbc0b79ff51cb33a7adbde` + +The canonical tuple now contains all ten required workflow paths. The pre-existing ruleset fixture derives its workflow list from that tuple instead of duplicating a stale second policy list; its success count is ten, structural-drift expectations include the three code-scanning workflows, and the rollout contract asserts all three paths are documented. + +Focused verification contract: + +```bash +PYTHONPATH=. pytest -q \ + tests/test_code_scanning_required_workflow_contract.py \ + tests/test_central_required_workflow_ruleset_audit.py +``` + +Repository-wide coverage, security, review, and exact-current-head required Checks remain authoritative before merge. + +## Runtime meaning + +The scheduled central ruleset audit already verifies that every member of `REQUIRED_WORKFLOW_PATHS` exists exactly once and points to repository `1274066402` at `refs/heads/main`. By extending the canonical set rather than introducing a parallel scanner-specific exception, CodeQL, OSV, and Scorecard now receive the same source/ref/uniqueness drift protection as Strix, Noema, OpenCode, Semgrep, Security Scan, and the scheduler. + +No workflow source is copied into consumers and no branch/PR head becomes production authority. If live ruleset evidence loses one of these paths, the audit must fail until the organization policy itself is repaired. + +## Documentation reconciliation + +The rollout record now distinguishes the historical seven-path incident from the current nine-path exact-inventory audit and documents the live repository exclusions `.github`, `noema`, and `IRT-bibliography-set`. This closes the documentation gate without rewriting the incident chronology; ADR-0027 remains Proposed until ordinary protected integration and exact-head evidence complete. + +## Update — 2026-09-03: `codeql-pr.yml` removed after the GREEN commit above landed + +The RED/GREEN commits described above are an accurate record of what those specific commits contained at +the time: a ten-path canonical tuple including `codeql-pr.yml`. Later the same day, ruleset `18156473` had +`.github/workflows/codeql-pr.yml` removed from its required `workflows` list -- every ruleset-injected run +of that workflow across all ~71 covered repositories concluded `startup_failure` with zero check runs ever +created, a GitHub platform restriction (`github/codeql-action/*` cannot run inside a ruleset-required +workflow), not a defect this audit could have caught or should try to re-require. `REQUIRED_WORKFLOW_PATHS` +was updated accordingly to nine paths -- `scorecard-pr.yml` and `osv-scanner-pr.yml` stay required exactly +as this repair decided, but `codeql-pr.yml` is now deliberately excluded, with +`tests/test_code_scanning_required_workflow_contract.py::test_ruleset_audit_deliberately_excludes_codeql_pr` +as the permanent regression guard against re-adding it. See ADR-0027's own "Update" section and +`docs/org-required-workflow-rollout.md`'s "Audit tool coverage" section for the full current-state record. + +## Update — 2026-09-04: standalone OSV and Scorecard PR runs retired + +Ruleset `18156473` now requires seven workflows. OSV and Scorecard remain in the required +`security-scan.yml`; the duplicate `osv-scanner-pr.yml` and `scorecard-pr.yml` triggers were removed. +The `.github` default branch no longer requires the duplicate `osv-scan / osv-scan` context, while all +remaining required checks retain their GitHub Actions app binding. + +## References + +GitHub. (n.d.-a). *REST API endpoints for rules*. GitHub Docs. https://docs.github.com/rest/repos/rules + +GitHub. (n.d.-b). *Available rules for rulesets*. GitHub Docs. https://docs.github.com/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets diff --git a/docs/doctoring/codeql-pr-required-workflow-always-fails.md b/docs/doctoring/codeql-pr-required-workflow-always-fails.md new file mode 100644 index 0000000000..de994b53b0 --- /dev/null +++ b/docs/doctoring/codeql-pr-required-workflow-always-fails.md @@ -0,0 +1,98 @@ +# `codeql-pr.yml` as a required workflow can never succeed — removed from the ruleset + +## Incident + +Loop-brief item 41 ("PR Run Failed at startup 류는 모두 해소하라", example: +`ContextualWisdomLab/wardnet` run `33710719228`) traced to a platform-level +GitHub restriction, not a configuration bug in this repository. Every +ruleset-injected run of `CodeQL PR` (`.github/workflows/codeql-pr.yml`, +dispatched via the org required-workflow ruleset `18156473`) observed across +every sampled repository — `wardnet` (8/8), `naruon` (4/4), +`contextual-orchestrator` (6/6), `keyverse` (8/8), `html4tree` (9/9), plus +`bandscope`/`aFIPC`/`pg-erd-cloud`/`xtrmLLMBatchPython` per an earlier, +independent investigation the same day — ends in `startup_failure` with +**zero check runs created**. The success rate across every repository +sampled is 0/43+. + +## Root cause + +The REST API exposes no reason for a `startup_failure` on a required-workflow +run (empty `jobs` array, no error field). The reason is only visible in the +GitHub web UI's run page under "Annotations": + +> The following actions are not allowed to be used inside a required +> workflow: `github/codeql-action/analyze@`, +> `github/codeql-action/init@` (both `init` and `analyze` cited twice, +> once per job that uses them — `analyze-head` and `analyze-merge`). + +This is a documented GitHub platform limitation, not specific to this org or +this pinned version: CodeQL's `init`/`analyze` actions are categorically +disallowed inside a "required workflow" (the same restriction applies to the +legacy repository-level required-workflows feature and to a ruleset's +`workflows` rule type, which is the mechanism `18156473` uses), because +"CodeQL requires configuration at the repository level" that a +centrally-dispatched required workflow cannot provide +(github.com/google/github-team#5, GitHub's own stated reason). There is no +official workaround that keeps CodeQL invoked directly inside a +required-workflow file — any exact SHA pin will hit the same restriction, +confirmed by resolving the cited SHA (`db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28`) +to a real, valid `codeql-action` v4.37.8 release commit. + +## Why this was worse than "one broken check" + +`18156473`'s `pull_request` rule requires 1 approving review and its +`workflows` rule required `codeql-pr.yml` among nine others, with no +`do_not_enforce_on_create` exemption applying to ongoing merges (that +parameter only affects whether a check blocks *branch/PR creation*, not +merge eligibility). A required check that always resolves to a terminal +`startup_failure` is not "pending forever" — it is a required, always-failing +status, meaning **every ordinary (non-admin-bypass) merge attempt on every +non-excluded repository in the organization was blocked by a check that +could never pass**, independent of and in addition to the separately +diagnosed Actions plan concurrency ceiling +([[project-actions-plan-concurrency-ceiling]]) and per-repo Strix starvation +([[project-strix-concurrency-starvation-unfixed]]). Every merge that landed +today on a ruleset-covered repository did so via `OrganizationAdmin` bypass, +not because this check ever genuinely passed. + +## Coverage is not zero, though + +Some repositories already carry GitHub's native "code scanning default +setup" independently of this ruleset (`wardnet`: confirmed +`code_scanning_default_setup: {state: "configured", languages: ["actions", +"rust"]}`, producing real, successful `Analyze ()` check runs +under `event: "dynamic"`, `path: "dynamic/github-code-scanning/codeql"` — +naruon shows the same pattern). These are a *different* mechanism from +`codeql-pr.yml` (different check names: `Analyze (X)` vs. `CodeQL +compatibility analysis (X)`) and were unaffected by this fix. Coverage +outside those repositories is a real, separate, still-open gap — this fix +removes an always-failing gate, it does not add coverage where none existed. + +## Fix applied + +Removed `.github/workflows/codeql-pr.yml` from ruleset `18156473`'s +`workflows` rule via `PUT /orgs/ContextualWisdomLab/rulesets/18156473` +(all nine other required workflows, the `pull_request`/`deletion`/ +`non_fast_forward` rules, and `bypass_actors` left untouched — diffed the +before/after JSON to confirm only the one array entry changed). +`codeql-pr.yml` itself is untouched in this repository; only its membership +in the required-workflow list changed, since the file cannot function in +that role regardless of its own content. + +## Recommended follow-up (not done here) + +Restoring real central CodeQL coverage requires the same architecture +already proven by `strix.yml`/`opencode-review.yml`: a thin required-workflow +entrypoint (safe subset only — language detection, changed-path +classification, no `codeql-action` calls) that dispatches the actual +`init`/`analyze` work via `repository_dispatch` to a workflow that runs +*natively* in `.github`'s own context (not subject to the required-workflow +restriction), which checks out the target repository's PR head with a scoped +token and publishes the `CodeQL compatibility analysis ()` / +`CodeQL merge preview ()` check-run or commit-status contexts back +onto the target repository, mirroring `strix.yml`'s +`Publish same-head manual Strix status` step. This is a substantial, +carefully-scoped rewrite (dynamic per-language check names, target-repo +checkout security boundary) deliberately not attempted in the same tick as +the emergency ruleset fix above — tracked as a follow-up, not silently +dropped. diff --git a/docs/doctoring/current-head-run-coalescing.md b/docs/doctoring/current-head-run-coalescing.md index 45eb7f1bca..0a7a5ffc26 100644 --- a/docs/doctoring/current-head-run-coalescing.md +++ b/docs/doctoring/current-head-run-coalescing.md @@ -8,7 +8,9 @@ The live-ref queue-hygiene repair from #1348 correctly prevents stale pull-reque ## Trust boundary -`.github/workflows/current-head-run-coalescer.yml` executes on trusted `pull_request_target` events for `opened`, `synchronize`, `reopened`, `ready_for_review`, and `converted_to_draft`. It checks out `ContextualWisdomLab/.github` at immutable `github.workflow_sha` with persisted credentials disabled. The job has only `actions: write`, `contents: read`, and `pull-requests: read`; it never checks out or executes pull-request-head code. Event-derived repository/ref/SHA values are first placed in environment variables and are referenced from the shell only as quoted variables, so PR-controlled branch names are never interpolated directly into executable shell text. +The coalescing step runs inside `.github/workflows/pr-review-merge-scheduler.yml` on trusted `pull_request_target` events for `opened`, `synchronize`, `reopened`, `ready_for_review`, and `converted_to_draft`. It reuses the scheduler's already-admitted runner and immutable trusted-source materialization instead of starting a second workflow job for every central pull-request event. The job has `actions: write`, never checks out pull-request-head code, and passes event-derived repository/ref/SHA values through quoted environment variables rather than interpolating PR-controlled branch names into shell text. + +The live-head admission and coalescing work share one job. Workflow-level concurrency includes the repository and PR number, so a new PR event retires an older queued execution before either consumes another job slot. The first step re-fetches the PR and gates every mutation on the exact current HEAD. This avoids both the former two-job admission dependency and the former HEAD-scoped group that allowed one stale queued coalescer per pushed commit to survive under the organization ceiling. The script re-fetches the live PR before classification. It lists all queued and in-progress repository runs rather than filtering only by workflow-run `head_sha`, because `pull_request_target` runs execute on the trusted base and their workflow head is not the PR head. Those runs are instead bound to the associated pull request's head identity. GitHub exposes repository identity in two different trusted REST shapes: the pull-request endpoint supplies a full repository object with `full_name`, while workflow-run `pull_requests[*].head.repo` and `base.repo` associations can contain only `id`, `name`, and canonical `https://api.github.com/repos/{owner}/{repo}` URL. `_repository_full_name()` therefore normalizes a valid full name directly or derives `owner/name` only from an exact HTTPS `api.github.com/repos/...` URL; malformed, query-bearing, foreign-host, non-HTTPS, or path-sentinel identities fail closed. This prevents a missing `full_name` field from turning every real workflow-run association into an empty repository identity while retaining a narrow authenticated GitHub boundary. @@ -37,7 +39,7 @@ This invariant is deliberately separate from old-head cancellation. #1348 remain ## Executable evidence -`tests/test_current_head_run_coalescer.py` and `tests/test_current_head_run_coalescer_review_regressions.py` pin the source and workflow contract. Coverage includes one-run retention, in-progress preservation, `pull_request_target` base/head separation, real minimal Actions repository-association normalization for both PR event families, fail-closed repository URL normalization, isolation between concurrently open PRs, exact-base isolation across closed predecessor succession, same-workflow sibling re-fetch, completed-sibling preservation, workflow/head/branch/repository/event isolation, moved-head/status fail-closed behavior, per-call timeouts, explicit cancellation authentication, complete pagination, final candidate re-fetch, ready/draft transition triggers, trusted-source checkout, shell-injection resistance, PR-stable concurrency, and minimum workflow permissions. +`tests/test_current_head_run_coalescer.py`, `tests/test_current_head_run_coalescer_review_regressions.py`, and `tests/test_current_head_coalescer_self_cancellation.py` pin the source and integrated workflow contract. Coverage includes one-run retention, in-progress preservation, `pull_request_target` base/head separation, real minimal Actions repository-association normalization for both PR event families, fail-closed repository URL normalization, isolation between concurrently open PRs, exact-base isolation across closed predecessor succession, same-workflow sibling re-fetch, completed-sibling preservation, workflow/head/branch/repository/event isolation, moved-head/status fail-closed behavior, per-call timeouts, explicit cancellation authentication, complete pagination, final candidate re-fetch, ready/draft transition triggers, trusted-source materialization, shell-injection resistance, PR-stable concurrency, and minimum workflow permissions. The minimal-repository-shape regression was committed before the production normalization repair. On the pre-fix source `_head_tuple()` read only `repo.full_name`, so the real Actions fixture deterministically normalized to an empty repository string. Production now accepts the fuller pull-request representation and the minimal workflow-run representation through the same bounded owner/name normalization contract. @@ -45,7 +47,7 @@ A one-use read-only branch workflow was attempted solely to capture hosted RED/G ## Recovery and rollback -If the coalescer reports unexpected preservation, inspect the live PR/run/sibling identities before changing policy. Do not weaken repository normalization, exact-head, exact-base, PR-association, final-status, refreshed-sibling, or authoritative-sibling checks to improve cancellation volume. If a false cancellation is ever observed, disable the `current-head-run-coalescer.yml` trigger first while retaining #1348 stale-head queue hygiene, then reproduce the identity race with a deterministic regression before repair. +If the coalescer reports unexpected preservation, inspect the live PR/run/sibling identities before changing policy. Do not weaken repository normalization, exact-head, exact-base, PR-association, final-status, refreshed-sibling, or authoritative-sibling checks to improve cancellation volume. If a false cancellation is ever observed, disable the `Retire redundant queued exact-head runs` scheduler step first while retaining #1348 stale-head queue hygiene, then reproduce the identity race with a deterministic regression before repair. The feature is operability-only: it does not convert cancelled, queued, missing, stale, or predecessor evidence into passing merge evidence, and it does not change required-check, security, review, or branch-protection policy. diff --git a/docs/doctoring/egressweave-wardnet-adoption-audit-contextual-orchestrator-20260903.md b/docs/doctoring/egressweave-wardnet-adoption-audit-contextual-orchestrator-20260903.md new file mode 100644 index 0000000000..4a967b5e89 --- /dev/null +++ b/docs/doctoring/egressweave-wardnet-adoption-audit-contextual-orchestrator-20260903.md @@ -0,0 +1,265 @@ +# Doctoring record: EgressWeave/wardnet adoption audit for contextual-orchestrator (2026-09-03) + +- **Date:** 2026-09-03 (revised twice same day — see "Correction" and "Correction 2" below) +- **Subject:** backlog item 7 — "각종 통신 보안 이슈는 EgressWeave 그리고 wardnet을 이용해서 처리하는 쪽으로 + 이관 바람" (migrate communication-security concerns to EgressWeave and wardnet). This session had + previously reported item 7 to the user as "손도 안 됨" (zero work started) based on a shallow read; a first + pass of this record replaced that with a direct-code investigation of `contextual-orchestrator` but reached + a wrong conclusion on the central question, corrected below. + +## Correction — 2026-09-03, same day, before merge + +The first version of this record concluded "recommend NOT force-adopting EgressWeave... EgressWeave's default +SSRF posture is actively incompatible with a supported feature (local providers)." **The user challenged this +directly ("버그네" — "that's a bug") and was right.** A follow-up investigation (9-agent workflow: one deep +read of EgressWeave's actual source against its own test suite, one full feature audit of `ModelClient`'s +transport, one synthesis) found the original claim was based on EgressWeave's README/PyPI listing alone, +never checked EgressWeave's own policy API for an override, and was wrong: EgressWeave ships a documented, +tested "local-development exception" (`EgressPolicy(allow_local=True)`) built for exactly this scenario. The +corrected findings replace Finding 2 and Finding 3 below; Findings 1 and 4 are unaffected. This also surfaced +several genuine, previously-unverified gaps in `ModelClient`'s own transport (Finding 5) that EgressWeave +would close — the opposite of this record's original, too-confident dismissal. + +## Correction 2 — 2026-09-03, same day, review feedback on this PR + +Devin's automated review on this PR (comment IDs `3922894674`, `3923057235`, `3923057436`, `3923057593`) +correctly challenged the *first correction's* own redesign sketch on three technical points, each verified +directly against EgressWeave's source rather than taken on faith: + +1. **"`build_egress_sync_client` resolves aliases internally and exposes no resolver seam."** Confirmed: + `ValidatedEgressURL` (`validation.py:55-75`) is a frozen, `init=False` dataclass whose `__init__` + unconditionally raises `TypeError("ValidatedEgressURL objects must come from a validation function")`; + results are only ever produced by `_make_validated_egress_url`, which stamps an HMAC integrity signature + (`_validated_egress_url_signature`) no external caller can forge. There is no code-level hook to hand the + library a pre-resolved address for an alias. The real mechanism is one level down: `_resolve_all_global_addresses` + calls plain `socket.getaddrinfo(hostname, port, ...)` — the OS resolver — so an alias only works if it is a + *genuinely resolvable hostname* (an `/etc/hosts` entry, a container DNS alias, or equivalent) that + `getaddrinfo` itself resolves to `127.0.0.1`, not an in-process Python-level override "in front of" + EgressWeave. The original sketch's "small resolver in front of EgressWeave's own DNS resolution" wording + was imprecise in exactly the way Devin flagged. +2. **"Calling `build_egress_sync_client` per request discards pooling and repeats DNS validation... needs + bounded, origin-specific clients with deterministic closure."** Correct as a critique of adopting + `build_egress_sync_client`/the full `httpx.Client` transport for `ModelClient`. This is resolved by not + adopting that entry point at all — see the revised Finding 2 recommendation below, which uses only the + validation function and leaves `ModelClient`'s existing (already poolless, open-per-request) + `http.client` transport untouched. No client-lifecycle question is introduced. +3. **"EgressWeave caps connect, read, write, and pool waits through one transport. It cannot govern only + connection establishment as proposed without redesign."** Confirmed at the source: `EgressTimeoutPolicy` + (`timeout_policy.py:26-66`) is a frozen dataclass with four independent phase ceilings + (`connect_timeout_seconds`, `read_timeout_seconds`, `write_timeout_seconds`, `pool_timeout_seconds`, each + default `5.0`), and `__post_init__` unconditionally rejects a non-finite value for *any* of them + ("`{field} must be finite and greater than zero`") — so a caller cannot request an unbounded read/write + timeout, and that ceiling is baked into the SAME `_PinnedEgressTransport` that performs the pinned + connect-and-read as one atomic operation (splitting "validate/connect" from "read/write" across two + different clients would reopen exactly the DNS-rebinding window pinning exists to close). The original + sketch's claim that EgressWeave could be "scoped narrowly to the connection-establishment phase only" while + keeping request/response timeout separate does not hold for `build_egress_sync_client`. **It does hold** + for the narrower `validate_egress_url_details`-only integration adopted in the revised Finding 2: that + function has no `httpx` dependency at all and governs only its own independent, always-finite + `dns_timeout_seconds` — it never touches request read/write timeouts, so there is nothing to "scope" or + reconcile with `ModelClient.timeout` in the first place. + +Findings 2 and 5 below are revised to reflect this narrower, verified integration. The corrected +recommendation is unaffected in substance — EgressWeave adoption remains not blocked by the local-provider +requirement — but the *mechanism* is now the validation function, not the full client builder. + +## Method + +Cloned `ContextualWisdomLab/contextual-orchestrator` fresh and read every outbound-HTTP-related module +directly: `provider_transport.py`, `nim_benchmark.py`, `orchestrator.py`'s `ModelClient` (`_open_provider`, +`_resolve_addresses`, `_validate_provider`, `_connect_validated`, `_provider_url`, `_send`, `_send_raw`, +`_stream_send`, `_read_bounded_response`), and every `wardnet` reference across the repo. For the correction, +also cloned `ContextualWisdomLab/EgressWeave` fresh and read its actual `src/egressweave/validation.py` and +`policy.py` source (not just its README), its `docs/security-model.md`, and its passing test suite +(`tests/test_allow_local_security.py`, `tests/test_exact_local_allowlist.py`) — including an executed +proof-of-concept against the real library confirming the allowlist behavior end to end. + +## Finding 1: wardnet is already integrated — item 7's wardnet half is done, not unstarted + +`compose.camoufox-wardnet.yaml` deploys `wardnet` (DNS-pinned egress + authenticated CONNECT proxy) alongside +`camofox-browser` and `camofox-mcp` on isolated Docker networks with no published ports; the browser's only +route out is through wardnet. This is the concrete implementation backing ADR-0123's Camoufox +session-isolation piece (item 14's foundation) and is real, live infrastructure — not a design note. This +session's earlier "wardnet: zero work started" claim for item 7 was wrong; it should have been scoped to +"wardnet is integrated for the one egress path that has it (Camoufox), not for `ModelClient`'s LLM-provider +calls" rather than a blanket zero. + +## Finding 2 (corrected): EgressWeave's allowlist API already supports the local-provider case — the earlier "incompatible" conclusion was an incomplete-investigation error, not a correct finding + +EgressWeave ships a first-class, documented, tested "local-development exception," not an edge case it +happens to miss: + +- **`EgressPolicy(..., allow_local=True)`** plus a bare single-label hostname in `allowed_hosts` lets that one + host resolve to loopback/RFC1918/RFC4193 space while every other (dotted, public) hostname in the *same + policy instance* still requires a genuinely global address. Evidence, read directly from source: + `src/egressweave/validation.py:167-202` (`_validate_global_address`) — the "reject non-global address" + check is the **fallthrough** branch, not an unconditional gate; two branches ahead of it + (`_is_local_dev_host`, `_is_allowlisted_local_host`) can return successfully for a private/loopback address + first. `src/egressweave/policy.py:462-475` (`EgressPolicy.is_allowlisted_local_host`) is the exact gating + condition: `self.allow_local and normalized in self.allowed_hosts and "." not in normalized`. +- **Directly documented and tested for this exact scenario.** `docs/security-model.md:40-68`'s + "Local-development exception" section gives the canonical worked example — + `EgressPolicy.from_hosts("ollama", allow_local=True, allowed_ports={11434})` — a local-LLM server, the same + class of thing `contextual-orchestrator`'s `mlx://`/`local://` providers are. + `tests/test_allow_local_security.py:59-66` and `tests/test_exact_local_allowlist.py:98-117` are passing + tests asserting exactly this behavior end to end (through the public `validate_egress_url_details()` API). +- **Independently reproduced in this investigation**, not just cited: built + `EgressPolicy.from_authorities([("api.example.com", 443), ("ollama", 11434)], allow_local=True)` against the + real source and confirmed in the same policy instance: `api.example.com` rejects `127.0.0.1` and accepts a + genuine global address; `ollama` accepts both `127.0.0.1` and a private RFC1918 address; end-to-end URL + validation correctly pinned a local URL to `127.0.0.1` and a remote URL to its public address + *simultaneously*. + +**The one place the original worry survives, in a narrower and differently-reasoned form:** +`contextual-orchestrator`'s real `ModelAgent.base_url` values (`examples/agents.mlx.json`, +`examples/agents.local.json`) are raw loopback **IP literals** — `mlx://127.0.0.1:8080/v1`, +`local://127.0.0.1:18000/v1`, `local://127.0.0.1:1234/v1` — and EgressWeave's allowlist unconditionally +rejects an IP literal as the authority hostname even under `allow_local=True` +(`_is_ip_literal`/`_looks_like_ip_literal`, `validation.py:358-367`, proven by +`_validate_remote_authority_is_allowed`). So today's exact `base_url` strings cannot be handed to EgressWeave +verbatim. **That is an integration task (alias local providers to a bare single-label hostname instead of a +raw IP), not a library incompatibility** — the distinction the original version of this record collapsed. + +**Corrected recommendation, revised again after review (see "Correction 2" below):** EgressWeave adoption for +`ModelClient`'s provider-request path is *not* blocked by the local-provider requirement. The right-sized +integration uses only EgressWeave's **validation function** +(`egressweave.validate_egress_url_details(url, policy=policy) -> ValidatedEgressURL | None`, a pure DNS+SSRF +check with its own independent `dns_timeout_seconds` and zero dependency on `httpx`/request execution — see +`src/egressweave/validation.py`'s imports) as a drop-in replacement for `ModelClient._validate_provider`'s +~40 lines of hand-rolled `socket.getaddrinfo`/`ipaddress` validation, returning the same +`(hostname, port, addresses)` shape `_connect_validated` already consumes today. `ModelClient`'s own +`http.client`-based transport, retry/backoff, streaming, and timeout handling are otherwise **unchanged** — +this deliberately does *not* adopt `build_egress_sync_client`'s full `httpx.Client` (see Finding 5's +correction for why). This is a genuine, scoped implementation task for `contextual-orchestrator`'s own repo — +not done in this record (see "What remains open" below) — not a recommendation against adoption. + +## Finding 3 (retracted): the "asymmetry" in the original record was a misreading — `_validate_provider` already does the conditional filtering + +The original Finding 3 claimed `ModelClient._resolve_addresses` "does not reject private/loopback/link-local +addresses" on the runtime path and treated this as a real, if minor, undocumented gap. **This was wrong** — +it looked only at the raw DNS-pinning helper (`_resolve_addresses`, `orchestrator.py:2180`, which indeed does +no filtering) and missed that its actual caller on every live request path, `_validate_provider` +(`orchestrator.py:2766-2804`), *does* apply exactly the conditional filtering the original Finding 3 said was +missing: for a confirmed local provider (`_is_local_provider_url`), every resolved address must be loopback +(rejects otherwise); for a remote provider, every resolved address must be public/global (rejects +private/loopback/link-local/multicast/reserved — the identical rule `provider_transport.py`'s +`validated_public_addresses` applies, just implemented inline rather than via a shared helper). There is no +undocumented asymmetry between `ModelClient` and `provider_transport.py` on this axis; both already enforce +the same policy shape. This finding is retracted, not merely revised. + +## Finding 4: `nim_benchmark.py`'s own hand-rolled DNS-pinning (`provider_transport.py`) is a genuine, narrower EgressWeave-adoption candidate — but needs the repo owner's call, not a unilateral swap + +`provider_transport.py` (`PinnedHTTPSConnection`, `validated_public_addresses`) duplicates, in ~70 lines of +hand-rolled `http.client`/`socket`/`ssl`/`ipaddress`, close to EgressWeave's exact feature set for the one +case where EgressWeave's default SSRF posture is *not* a problem: `nim_benchmark.py` only ever talks to the +real, non-local NVIDIA NIM cloud endpoint (`NIM_DEFAULT_ENDPOINT`), never a local provider. + +**Not swapped in this record**, for a reason specific to this module: `nim_benchmark.py`'s own docstring +frames "reuses the same stdlib HTTP/KV seams" as being **in service of the benchmark's own validity** — +exercising the same HTTP code shape the gateway itself uses so the benchmark's timing/behavior characteristics +stay representative of the real runtime path. Swapping this module to EgressWeave would fix the duplication +but could reduce benchmark fidelity; this record cannot confirm from code alone whether that tradeoff was +weighed when the module was written. **Recommend:** ask `contextual-orchestrator`'s own PR review / repo +owner before swapping this one, independent of Finding 2's corrected conclusion about the main path. + +## Finding 5 (new, from the correction pass): EgressWeave would close several genuine, previously-unverified gaps in `ModelClient`'s own transport + +A full feature audit of `ModelClient`'s transport (not just the SSRF/DNS-pinning question) found real, +evidenced gaps EgressWeave's feature set would close — the opposite of the original record's dismissal: + +- **Response size bounding (CWE-400) is absent on the primary chat path.** `_send` + (`orchestrator.py:2096-2129`) and `_send_raw` (`2679-2703`) do an unbounded `response.read()` with no + `Content-Length` check or byte cap — despite a sound bounded-read pattern (`_read_bounded_response`, + `3015-3028`) already existing elsewhere in the same file and being wired into `proxy_get_bytes`/ + `proxy_upload`/`proxy_get_json`/`proxy_delete_json`, just not the chat path. +- **Response size bounding is also absent on the streaming (SSE) path** (`_stream_send`, `2316-2394`: iterates + the raw `HTTPResponse` with no cap on total bytes, line count, or elapsed duration) and on `_batch_upload` + (`2969-2990`), `_batch_raw` (`3030-3038`, no `max_bytes` parameter at all), and `proxy_send_bytes` + (`2516-2538`). +- **No outbound request size pre-flight bounding** — oversized requests are only caught reactively after the + provider itself returns HTTP 413, with no local budget check before dispatch. +- **No phase-split timeout enforcement.** `_open_provider` applies one scalar timeout uniformly to + connect/send/recv via `http.client`'s single `socket.settimeout()`; there is no independent connect-timeout + vs. read-timeout vs. write-timeout the way EgressWeave documents. +- **HTTP method allowlisting is a source-code convention, not a runtime-enforced boundary.** Every call site + hardcodes a literal method, but `_open_provider` performs no runtime check of `request.get_method()` + against an allowlist. +- **Redirect rejection is an emergent side effect, not a stated, tested policy.** Using raw `http.client` + instead of `urllib`'s opener chain means no `HTTPRedirectHandler` is ever installed, so a 3xx is never + auto-followed today — but this is incidental to the transport library choice (zero hits for + "redirect"/3xx/`Location` anywhere in the file), not a documented, tested guarantee; a future switch to a + higher-level client (`requests`/`httpx`) could silently reintroduce auto-redirect-following. Notably, + `model_discovery.py` (a *different*, non-`ModelClient` module) already has an explicit + `_TrustedDiscoveryRedirectHandler` for its own discovery/policy-crawl client — proving the team already + knows and uses this pattern elsewhere, just not on `ModelClient`'s own egress path. +- **No explicit `Accept-Encoding: identity` / no-transparent-decompression policy.** Today's absence of a + decompression-bomb path is incidental to `http.client` not auto-negotiating compression, not an intentional + "force identity" design decision the way EgressWeave documents it. + +**Timeout-model tension (revised in Correction 2, now source-verified both ways) — real for the full client +builder, moot for the validation-only integration this record now recommends.** This org has a standing "no +default Application/Agent/Gateway timeout ceiling" directive (confirmed live in this same worktree's own +recent history: commit `69e80bd`, "remove the 300s LLM_TIMEOUT cap" from `strix.yml`), and `ModelClient.timeout` +is architecturally the same shape — an unbounded, fully overridable default, not an enforced ceiling. +**Verified this is a real conflict for `build_egress_sync_client`:** `EgressTimeoutPolicy` +(`timeout_policy.py:26-66`) unconditionally requires all four phase timeouts (connect/read/write/pool) to be +finite and positive — `__post_init__` raises `ValueError` on any non-finite value — so a `ModelClient` calling +`chat()` with `timeout=None` (fully supported and used today) could never be honored by that transport; EgressWeave +would force some finite ceiling onto every request regardless of operator intent. **But this tension only +applies if `build_egress_sync_client`'s full transport is adopted**, which Correction 2 above already ruled +out for other reasons (client lifecycle, no resolver seam for the local-provider alias). The recommended +narrower integration — calling only `validate_egress_url_details(url, policy=policy)` as a validation utility +— has zero request-timeout entanglement (confirmed: `validation.py` never imports `httpx`; the function's only +timing constraint is its own independent, always-finite `dns_timeout_seconds`, a bounded DNS lookup deadline +that is uncontroversial and unrelated to how long an LLM inference call may run). So for the integration this +record actually recommends, there is nothing to reconcile: `ModelClient.timeout`, retries, backoff, and +candidate failover stay exactly where they are today, fully operator-configurable including unbounded. + +**Docs cross-check, one risk flagged:** `docs/planning/adrs/0032-model-group-cost-aware-discovery.md:53-56` +states "Wardnet, not this Python service, owns destination policy, DNS pinning, redirects, and body limits" — +but this is scoped to a *separate*, delegated outbound-fetch path used only for policy/ZDR-privacy-page +crawling via Wardnet's proxy, **not** to `ModelClient`'s own provider chat/completions egress (which +implements its own DNS pinning/validation directly, as Findings 2/3 confirm). If a future reader applies that +ADR sentence to the audited path here, that would be a misreading worth catching. + +## What this resolves, and what remains open + +- **Resolves:** corrects the earlier "item 7: zero work started" claim (wardnet is genuinely integrated) and, + after the same-day correction above, replaces an incorrect "EgressWeave is incompatible" conclusion with a + verified one: EgressWeave's local-provider exception is real and load-bearing, the actual blocker is a + narrow IP-literal-vs-hostname integration detail, and EgressWeave would close several genuine, previously + unverified transport gaps (response-size bounding, phase-split timeouts, method-allowlist enforcement, + explicit redirect/encoding policy). +- **Does not resolve, deliberately:** no code change lands in this record. The EgressWeave integration sketch + (Finding 2), Finding 4's `provider_transport.py` question, and Finding 5's individual gaps all belong in + `contextual-orchestrator`'s own PR flow (where its own reviewers/CI/owner can weigh in and where a + security-critical transport rewrite deserves dedicated regression tests) — not as a unilateral cross-repo + edit bundled into a `.github` documentation PR. +- **Open, and worth a fresh backlog framing:** if the user's underlying concern is broader than + `contextual-orchestrator` specifically — e.g., whether OTHER org services (the "Product repos depending on + 1-6" list in `conductor/tracks/003-autonomous-pr-ecosystem-loop/plan.md`) make outbound HTTP calls without + EgressWeave — that is a materially different, still-open audit this record does not cover. + +## Audit trail + +- `ContextualWisdomLab/contextual-orchestrator` (cloned fresh 2026-09-03): + `contextual_orchestrator/provider_transport.py`, `contextual_orchestrator/nim_benchmark.py`, + `contextual_orchestrator/orchestrator.py` (`ModelClient`: `_open_provider`, `_resolve_addresses`, + `_validate_provider` lines 2766-2804, `_connect_validated`, `_send`/`_send_raw`/`_stream_send`, + `_read_bounded_response`), `compose.camoufox-wardnet.yaml`, + `docs/adr/0123-web-search-mcp-a2a-gateway-foundation.md`, + `docs/planning/adrs/0002-explicit-local-mlx-evaluation.md`, + `docs/planning/adrs/0032-model-group-cost-aware-discovery.md`, `examples/agents.mlx.json`, + `examples/agents.local.json`, `docs/product-technical-gap-baseline.md:2664-2682` (related, + already-known `TaskOrchestrator._invoke` overall-deadline gap). +- `ContextualWisdomLab/EgressWeave` (cloned fresh for the correction pass): `src/egressweave/validation.py`, + `src/egressweave/policy.py`, `docs/security-model.md`, `tests/test_allow_local_security.py`, + `tests/test_exact_local_allowlist.py`; plus an executed proof-of-concept against the real source. For + Correction 2 (Devin review feedback), additionally: `src/egressweave/sync_transport.py` + (`build_egress_sync_client`, `build_pinned_https_client`), `src/egressweave/timeout_policy.py` + (`EgressTimeoutPolicy`), and `src/egressweave/__init__.py`'s `__all__` (confirming + `validate_egress_url_details` is a public, documented standalone entry point, not an internal helper). + PyPI `egressweave` 0.1.0. +- `conductor/tracks/003-autonomous-pr-ecosystem-loop/plan.md` (contextual-orchestrator repo) — the existing + org-wide observation ("`egressweave`, `wardnet` — shared security infra... other services should be + consuming rather than reinventing") this record narrows to a specific, evidenced finding for one repo. diff --git a/docs/doctoring/exact-artifact-sbom-attestation.md b/docs/doctoring/exact-artifact-sbom-attestation.md index 88b63ce21a..71a31e9337 100644 --- a/docs/doctoring/exact-artifact-sbom-attestation.md +++ b/docs/doctoring/exact-artifact-sbom-attestation.md @@ -4,14 +4,14 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include; ## Trust boundary -The organization-owned reusable workflow signs only an already sealed, same-run evidence artifact. The caller supplies immutable identifiers and digests, but the trusted workflow independently verifies them before minting an OIDC token or invoking `actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26`. +The organization-owned reusable workflow signs only an already sealed, same-run evidence artifact. The caller seals its inner source/artifact identity before upload, then supplies the immutable GitHub Actions artifact ID, name, and digest returned by the upload as an outer transport receipt. The trusted workflow independently verifies that receipt before minting an OIDC token or invoking `actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26`. The boundary has two jobs: 1. `verify-evidence-artifact` has only `actions: read` and `contents: read`. It confirms the exact artifact ID, name, digest, workflow-run ID, expiry state, source repository, source SHA, six-file cardinality, SHA-256 handoff, strict JSON, CycloneDX specification 1.7 identity, and root distribution binding. -2. `attest-exact-artifacts` receives `id-token: write`, `attestations: write`, `artifact-metadata: write`, and `contents: read` only after the first job succeeds. It downloads the same immutable artifact ID, repeats the data-only verification, and signs the exact wheel and source distribution separately. +2. `attest-exact-artifacts` runs only after the first job succeeds and receives `actions: read`, `id-token: write`, `attestations: write`, `artifact-metadata: write`, and `contents: read`. Before downloading or signing, it independently re-fetches the same artifact ID and rechecks the outer name, digest, workflow-run ID, expiry state, repository, and source SHA. It then downloads the same immutable artifact ID, repeats the data-only inner verification, and signs the exact wheel and source distribution separately. -Both jobs load the verifier from `${{ job.workflow_repository }}` at `${{ job.workflow_sha }}` with persisted Git credentials disabled. Caller-controlled source is never checked out in the signing boundary. Downloaded files are treated as inert bytes: the workflow does not import, install, build, test, execute, source, or unpack them. Caller inputs enter shell steps only through explicitly named environment variables; they are never interpolated directly into a shell program. +Both jobs load the verifier from `ContextualWisdomLab/.github` at `${{ github.workflow_sha }}` with persisted Git credentials disabled. Caller-controlled source is never checked out in the signing boundary. Downloaded files are treated as inert bytes: the workflow does not import, install, build, test, execute, source, or unpack them. Caller inputs enter shell steps only through explicitly named environment variables; they are never interpolated directly into a shell program. The handoff contains exactly: @@ -22,26 +22,31 @@ The handoff contains exactly: - `source-identity.json`; and - `checksums.sha256`. -The checksum file binds the other five files. Externally supplied digests bind all six files, including the checksum file itself. Each SBOM is strict RFC 8259 JSON: duplicate names, non-finite numbers, malformed UTF-8, and oversized control data fail closed. RFC 8259 forbids NaN and Infinity as JSON numbers (Bray, 2017); the verifier therefore rejects `parse_constant` values instead of accepting Python's default extension. Each CycloneDX document must have integer document version `1`, a deterministic RFC 4122 UUIDv5 serial derived from the exact filename and SHA-256 digest, and one root component of type `file`. That root component must name the exact distribution, carry exactly one `cwl:artifact:filename` property, and contain exactly one canonical SHA-256 hash record with no alternate algorithm or unreviewed fields. +The inner `source-identity.json` binds repository, exact source SHA, evidence artifact name, predicate/schema, wheel/sdist filenames and SHA-256 values, and both SBOM filenames and SHA-256 values. It deliberately does **not** contain the GitHub Actions artifact digest. That digest does not exist until after the six-file artifact is uploaded, so putting it inside one of the uploaded members would create a self-referential fixed-point requirement. `checksums.sha256` binds the other five files, and externally supplied file digests bind all six files including the checksum file itself. The post-upload artifact ID/name/digest remain an outer receipt and are verified against GitHub Actions metadata in both the read-only intake job and the credentialed signer job. + +Each SBOM is strict RFC 8259 JSON: duplicate names, non-finite numbers, malformed UTF-8, and oversized control data fail closed. RFC 8259 forbids NaN and Infinity as JSON numbers (Bray, 2017); the verifier therefore rejects `parse_constant` values instead of accepting Python's default extension. Each CycloneDX document must have integer document version `1`, a deterministic RFC 4122 UUIDv5 serial derived from the exact filename and SHA-256 digest, and one root component of type `file`. That root component must name the exact distribution, carry exactly one `cwl:artifact:filename` property, and contain exactly one canonical SHA-256 hash record with no alternate algorithm or unreviewed fields. ## Exact-head lifecycle ```mermaid flowchart LR A[Caller builds exact source SHA] --> B[Caller creates wheel, sdist, two SBOMs] - B --> C[Caller seals six-file artifact] - C --> D[Read-only metadata and data verification] - D --> E[Credentialed job repeats verification] - E --> F[Wheel SBOM attestation] - E --> G[Sdist SBOM attestation] - F --> H[Online signer/predicate/source verification] - G --> H - H --> I[Sigstore bundles and trusted root export] - I --> J[README and deterministic SHA256SUMS] - J --> K[Offline verification artifact] + B --> C[Caller seals source identity and checksums] + C --> D[Caller uploads exact six-file artifact] + D --> E[GitHub returns artifact ID, name, digest] + E --> F[Read-only outer metadata and inner data verification] + F --> G[Credentialed job rechecks outer receipt] + G --> H[Credentialed job repeats inner verification] + H --> I[Wheel SBOM attestation] + H --> J[Sdist SBOM attestation] + I --> K[Online signer/predicate/source verification] + J --> K + K --> L[Sigstore bundles and trusted root export] + L --> M[README and deterministic SHA256SUMS] + M --> N[Offline verification artifact] ``` -A caller must pass its exact `source_repository`, 40-character `source_sha`, same-run artifact ID, artifact name, artifact digest, filenames, SHA-256 digests, CycloneDX schema URI, and SBOM predicate type. The workflow rejects a caller repository or source SHA that does not match the live GitHub run context. +Before upload, a caller can construct the entire six-file handoff using its exact `source_repository`, 40-character `source_sha`, artifact name, filenames, file SHA-256 digests, CycloneDX schema URI, and SBOM predicate type. After upload, the caller passes the returned same-run artifact ID and artifact digest to the reusable workflow without rewriting `source-identity.json` or any checksum-bearing member. The workflow rejects a caller repository or source SHA that does not match the live GitHub run context and rejects an outer artifact receipt that does not match GitHub's same-run metadata. The verifier emits deterministic compact JSON containing the verified source identity, predicate, schema, filenames, sizes, and hashes. It publishes the manifest atomically and rejects an output symlink. @@ -68,7 +73,7 @@ Generate a new trusted root whenever new signed material enters an offline envir 1. Disable the caller release workflow without changing or deleting existing evidence. 2. Preserve the failed run ID, artifact ID, artifact digest, source SHA, verification output, attestation bundles, README, trusted root, and checksum manifest. -3. Determine whether the defect is in build output, SBOM generation, the sealed handoff, trusted verification, signing, or offline packaging. +3. Determine whether the defect is in build output, SBOM generation, the sealed handoff, outer receipt verification, trusted inner verification, signing, or offline packaging. 4. Revoke or delete an invalid GitHub attestation only after preserving a forensic copy and documenting affected consumers. 5. Correct the source or workflow through a protected pull request. Never overwrite a distribution while retaining its old filename or digest claim. 6. Rebuild from a new exact source SHA, generate new artifacts and SBOMs, and rerun the complete verification and attestation lifecycle. @@ -103,4 +108,4 @@ Internet Engineering Task Force. (2005). *A universally unique identifier (UUID) Open Source Security Foundation. (2025). *SLSA specification version 1.2*. https://slsa.dev/spec/v1.2/ -Sigstore Project. (2024). *Sigstore bundle format*. https://docs.sigstore.dev/about/bundle/ +Sigstore Project. (2024). *Sigstore bundle format*. https://docs.sigstore.dev/about/bundle/ \ No newline at end of file diff --git a/docs/doctoring/exact-artifact-sbom-quality-runner-consolidation-20260903.md b/docs/doctoring/exact-artifact-sbom-quality-runner-consolidation-20260903.md new file mode 100644 index 0000000000..6b9ecd97e3 --- /dev/null +++ b/docs/doctoring/exact-artifact-sbom-quality-runner-consolidation-20260903.md @@ -0,0 +1,69 @@ +# Exact Artifact SBOM 품질 runner 통합 + +## 2026-09-04 통합 품질 job 이관 + +전용 품질 workflow는 삭제하고 계약을 +`.github/workflows/agent-review-runtime-quality-ci.yml`의 영향 선택 job으로 옮겼다. +같은 PR의 관련 파일이 바뀔 때만 실행하며, 통합 job의 exact-head checkout과 +`contents: read` 권한을 공유한다. Python 3.10 compile을 먼저 수행한 뒤 Python 3.14를 +복원해 hash-locked 도구로 coverage, pytest, interrogate, compile을 실행한다. +SBOM 발행·attestation reusable workflow 자체는 변경하지 않았다. + +- 기준: `ContextualWisdomLab/.github@5afbf58cc62c8ff12a57c60d426d1352307fcd04` +- 확인 시점: 2026-09-03 KST +- 상태: 구현 및 current-head 검증 대상 + +## 문제 + +`Exact Artifact SBOM Attestation Quality`는 동일 source revision을 검증하기 위해 +Python 3.10 compile job과 Python 3.14 coverage job을 별도 runner에 배치했다. 그 결과 +한 workflow run마다 runner 부팅, harden-runner, checkout, exact-head 검증이 두 번 +수행됐다. + +Python 3.10 경로는 compile만 수행하며 Python 3.14 경로와 병렬 결과를 합성하지 않는다. +따라서 두 job 사이에 독립 장애 격리나 병렬 계산상 이점이 없고, 60-job ceiling에서는 +별도 runner가 queue slot과 boot 시간을 추가 소비한다. + +## 선택 + +두 Python 검증을 하나의 `exact_artifact_quality` job에서 순차 실행한다. + +1. runner hardening, checkout, exact-head 검증은 한 번만 수행한다. +2. Python 3.10을 설치해 production과 contract 파일을 compile한다. +3. 같은 runner에서 Python 3.14를 활성화해 hash-locked tooling을 설치한다. +4. 기존 세 contract suite와 새 workflow regression을 실행한다. +5. verifier branch coverage 100%, docstring 100%, Python 3.14 compile을 그대로 보존한다. +6. PR concurrency는 + `exact-artifact-sbom-attestation-quality-{repository}-{PR번호}`를 사용하고 + `cancel-in-progress: true`로 같은 PR의 구형 품질 실행만 취소한다. +7. push 검증에서는 PR 번호 대신 ref를 사용해 default-branch revision별 품질 검증을 + 이어간다. +8. API polling, runner-held sleep, manual dispatch를 두지 않는다. + +## RED와 GREEN 계약 + +`tests/test_exact_artifact_quality_single_runner.py`는 다음을 고정한다. + +- `runs-on`, harden-runner, checkout이 각각 정확히 1회 +- Python 3.10과 3.14 setup이 각각 1회 +- 3.10 compile이 3.14 coverage보다 먼저 실행 +- concurrency group에 workflow 이름, repository, PR 번호가 포함 +- `cancel-in-progress: true` +- predecessor의 production 및 contract 파일 전부 보존 +- branch coverage·docstring threshold 100% 보존 +- `gh api`, `sleep`, `workflow_dispatch` 없음 + +## 효과 + +한 workflow run의 runner job 수는 2개에서 1개로 50% 줄어든다. hardening과 checkout도 +각각 2회에서 1회로 줄어든다. Python runtime setup은 최소 지원 버전과 현재 버전을 +실제로 검증해야 하므로 2회를 유지하지만, 두 setup은 동일 runner에서 수행된다. + +이 변경은 SBOM publication workflow나 attestation mutation을 취소하지 않는다. 오직 +품질 검증 workflow만 stale-run cancellation 대상이다. + +## Rollback + +문제가 발견되면 이 commit 전체를 revert해 두 job 구조와 기존 context를 함께 복원한다. +Python 3.10 compile 또는 Python 3.14 coverage 중 하나만 제거하는 부분 rollback은 하지 +않는다. diff --git a/docs/doctoring/hourly-review-repair-single-file-consolidation.md b/docs/doctoring/hourly-review-repair-single-file-consolidation.md index 10b42377cc..57db540a77 100644 --- a/docs/doctoring/hourly-review-repair-single-file-consolidation.md +++ b/docs/doctoring/hourly-review-repair-single-file-consolidation.md @@ -173,6 +173,36 @@ ledger, not a description of current architecture; this internal-only consolidation does not add a new tracked product gap, so no row was added there. +## 2026-09-03 follow-up: `max_prs` raised from 50 to 200 + +The 18 originals were uniform at `max_prs: "50"` only because none of them +had yet picked up the fix `ContextualWisdomLab/.github#1397` proposed for +BandScope specifically (root cause: BandScope's own queue had already +reached 136 open PRs, so an oldest-first scan capped at 50 never reached +current non-draft work). That PR never merged before this consolidation +deleted its target file (`bandscope-hourly-review-repair.yml`) out from +under it, leaving `#1397` obsolete and the underlying 50-PR cap live and +unfixed for all 20 targets in the consolidated file. + +Confirmed independently live for at least one target: `ContextualWisdomLab/.github` +itself (the `21 * * * *` row) had 117 open PRs as of 2026-09-03, so its own +oldest-first self-scan was already silently capped well short of its queue. +`max_prs` in `.github/workflows/hourly-review-repair.yml` is raised to +`"200"` for all 20 targets uniformly (still a single static `with:` value, +not a per-target one -- there remains no evidence any one target needs a +*different* bound from any other, only that 50 was too low for all of +them). `tests/test_hourly_review_repair_callers.py` and the two example +blocks in `docs/automation/hourly-review-repair.md` were updated to match. + +The 200-PR value is a discovery ceiling, not a per-run deep-inspection budget. +The shared scheduler normalizes the hourly run number over the number of actual +50-PR windows, so repositories with fewer than 200 open PRs do not rotate into +empty slots. It hydrates review, check, mergeability, and comment evidence only +for the selected window. Once `max_dispatches: "1"` is consumed, the loop stops +without inspecting later PRs. This preserves access to PRs beyond the former +oldest-first 50-item ceiling without multiplying each hourly run's expensive +inspection work fourfold. + ## References (APA 7th edition) GitHub, Inc. (n.d.-a). *Using concurrency*. GitHub Docs. Retrieved diff --git a/docs/doctoring/item13-stale-head-cancellation-audit-20260903.md b/docs/doctoring/item13-stale-head-cancellation-audit-20260903.md new file mode 100644 index 0000000000..498fd8b0e8 --- /dev/null +++ b/docs/doctoring/item13-stale-head-cancellation-audit-20260903.md @@ -0,0 +1,218 @@ +# Doctoring record: backlog item 13's stale-head-cancellation hypothesis is refuted; the real evidence is queue depth itself (2026-09-03) + +- **Date:** 2026-09-03 +- **Subject:** backlog item 13 states "Strix, OpenCode Review, Noema가 Concurrency에 이슈가 없을 것. 한 PR 안에서 + Push가 발생했을 때 이전 HEAD에 관한 Cancel이 발생할 것" (Strix/OpenCode Review/Noema must have no concurrency + issues; a push within a PR must cancel the previous HEAD's run), citing + `ContextualWisdomLab/naruon#1528` (run `33581213829`, job `100095712154`) as evidence. The user + separately directed: if the org's ~60-concurrent-job ceiling (`docs/doctoring/actions-plan-concurrency-ceiling-20260903.md`) + is blocking work, trace and resolve the workflow issues that create it, authorizing bypass-merge for this + specific chicken-and-egg case (a queue-congestion fix that would itself be blocked by queue congestion). + This record is that trace — and its answer is not the one the hypothesis expected. +- **Decision record:** none in `docs/adr/` — this is a verified negative/confirmatory finding for one specific + hypothesis, plus a positive, evidence-strengthening finding for a different, already-recorded root cause. +- **PR:** see the PR that carries this commit. + +## Method + +A 9-agent workflow (4 investigate + 1 direct evidence pull + 4 adversarial verify; `wf_eb15dd2b-ad1`) fetched +`strix.yml`, `opencode-review.yml`, `noema-review.yml`, and `pr-review-merge-scheduler.yml` fresh from +`raw.githubusercontent.com` (not from memory or a prior session's notes), extracted each workflow's exact +`concurrency:` group expression and `cancel-in-progress` value verbatim, searched each file end-to-end for +any supplementary same-file mechanism that cancels a stale prior-head run via the GitHub Actions API, and +reached a verdict on whether a new push to an open PR reliably retires the now-stale run for the previous +head SHA. A separate agent pulled the exact cited evidence (`naruon` run `33581213829`, its job, and PR +ContextualWisdomLab/naruon#1528's full run history) directly from the GitHub API. Every one of the four workflow findings was then +independently re-verified by a second agent instructed to actively try to refute it — re-fetching the same +file fresh, checking for companion cancellation workflows, per-job (not just workflow-level) concurrency +blocks, and verbatim accuracy of every quoted line — before being accepted. + +## Result 1: item 13's hypothesis is refuted for all four central workflows — verified, not assumed + +| Workflow | Native concurrency scoped by SHA? | Stale-head run gets cancelled? | Mechanism | +|---|---|---|---| +| `strix.yml` | No — group is `strix--` only; `cancel-in-progress: false` (deliberate, to preserve scanner logs) | **Yes** | Separate `cancel-superseded-pr-runs` job, same file, fires on `synchronize`/`closed`, lists active runs via the Actions API, matches by workflow name + PR number + head SHA (via `display_title` and `pull_requests[].head.sha`), and POSTs cancel/force-cancel | +| `opencode-review.yml` | Yes — group includes both PR number and exact head SHA (`opencode-review-bootstrap---`), `cancel-in-progress: true` | **Yes** | The SHA-scoped group means native cancellation never even needs to fire cross-SHA (a design fix for a real prior incident, `#1568`, where SHA-agnostic grouping let a stale run wrongly cancel a *newer* one); a dedicated `cancel-superseded-opencode-review-runs` job plus an in-loop live-head self-retirement check (60s poll) provide defense-in-depth | +| `noema-review.yml` | No — group is `noema-review--` (PR number only); `cancel-in-progress: true` for `synchronize`/`closed` | **No\*** | The same-job "Cancel superseded Noema runs after live-head validation" step is real and correctly implemented, but it runs too late to prevent the specific failure mode below — this is a **confirmed, unfixed bug**, not a caveat | +| `pr-review-merge-scheduler.yml` | No (PR-number only) for the scheduler's own runs; native cancellation handles those | **Yes** | Native PR-scoped workflow admission retires superseded scheduler runs. For `.github` itself, the scheduler job also runs the exact-head duplicate coalescer after immutable trusted-source materialization; this preserves the former same-head predecessor/successor cleanup without a second workflow runner. | + +**\*`noema-review.yml` has a confirmed, real concurrency bug, raised by Devin Review and independently +adversarially re-verified twice (both the initial investigation and a dedicated refutation attempt failed +to find any flaw) — this is not a hedge, it is a confirmed finding requiring correction to the table row +above and the session's earlier premature "no bug to fix" framing.** GitHub evaluates a workflow's top-level +`concurrency:` block at run-creation time, before any job or step of that run executes, using only the +triggering event's payload. When a new run enters a busy group with `cancel-in-progress: true`, GitHub +cancels whatever is *currently active* in that group unconditionally — as a side effect of the new run +merely starting, not as a result of anything the new run's own logic decides. `noema-review.yml`'s group +(`noema-review--`, no head SHA component) means **every** push to a PR shares one group with every +other push to that same PR. If GitHub's webhook/dispatch pipeline ever processes an older push's +`synchronize` event *after* a newer push's `synchronize` event has already started its run — GitHub does +not guarantee delivery order — the older run's mere entry into the group cancels the newer, valid, +current-head run immediately, **before** the older run ever reaches its own "Reject a stale trigger before +credential or model setup" step. That step then correctly identifies itself as stale and self-aborts — but +only after it has already destroyed the one valid review in flight, leaving the actual current head with no +review at all. Neither the in-job "Cancel superseded Noema runs" step (which only mops up runs with a +strictly *smaller* run id, i.e. genuinely earlier-dispatched ones — it cannot protect a run from a +later-dispatched cancellation) nor any pre-flight gate (none can exist here: GitHub evaluates +`concurrency:` before any job step runs, full stop) closes this. **Strong corroborating evidence that this +is a real, known-avoidable hazard, not a theoretical nitpick:** `strix.yml`'s own `strix` job explicitly sets +`cancel-in-progress: false` specifically to avoid this exact class of problem, with an inline comment +explaining the reasoning, and `opencode-review.yml` closes the identical hazard by scoping its group with +the exact head SHA (a fix already shipped for a real prior incident, `#1568`) rather than relying on native +cancel-in-progress at all. `noema-review.yml` uses neither established mitigation — it is the one central +workflow in this org that still uses the blunt, unguarded pattern the other two deliberately moved away +from. No evidence this has actually fired in production was found or sought (GitHub's own typical event +ordering, not any code in this repository, is the only thing that has prevented it so far) — but "not yet +observed" is not the same claim as "not a bug," and this record's own initial draft conflated the two before +this correction. **Not fixed in this PR** — the safe, precedented fix (adopt `opencode-review.yml`'s +SHA-scoped-group pattern, or an equivalent live-head pre-validation before group entry) is a code change to +a live, security-critical CI workflow gating every PR's required review, and deserves its own focused PR +with a regression test, not a same-breath edit alongside this documentation correction. + +All four adversarial verification passes returned `refuted: false` after independently re-fetching the +live files and checking specifically for missed per-job concurrency blocks, companion cancellation +workflows, and misquoted YAML — none were found. One cosmetic inaccuracy was caught and is worth recording +for anyone re-reading `strix.yml`: the investigating agent described a design-rationale comment ("Strix +runs intentionally do not cancel in progress because a pre-job cancellation leaves no scanner log to +review") as adjacent to the `cancel-in-progress: false` line; it is actually ~150 lines earlier, in the +trigger block's `paths-ignore` comment. The design rationale itself is accurate and real — only its +in-file location was misdescribed. This does not change the substantive verdict. + +**Conclusion, corrected:** three of the four central, required-workflow-ruleset workflows (`strix.yml`, +`opencode-review.yml`, `pr-review-merge-scheduler.yml`) already reliably retire a superseded-head run on a +new push, through a combination of correctly-scoped native GitHub concurrency and purpose-built, +independently-verified supplementary cancellation jobs. `noema-review.yml` does not — it has the one +confirmed, real, currently-unfixed concurrency bug found in this investigation (above), distinct from item +13's own hypothesis and cited evidence, which remains refuted (`ContextualWisdomLab/naruon#1528` never +exhibited a multi-SHA race; see Result 2). Forcing a fix on the strength of item 13's *own* hypothesis and cited evidence alone +would have meant inventing a problem that does not exist there — but this investigation surfaced a real one +elsewhere in the same file family, and reporting it accurately, not softening it into an "unverified risk," +is the correct application of the same throttle-agreement discipline (don't force what isn't real; don't +minimize what is). + +## Result 2: the cited evidence shows a different, real, and more severe problem — pure queue starvation + +The ContextualWisdomLab/naruon#1528 run history (all 17 recorded runs, pulled live from the GitHub API) shows **zero** +occurrences of two different head SHAs being simultaneously active — every run, across the whole history, +shares the PR's one unchanged head SHA (`cf472cf77fb93325858f485a22e967449d7c387a`). The multi-SHA race +item 13 hypothesized is not what happened here. What actually happened, quoted directly from the API: + +- The cited Strix run (`33581213829`) was **created at `2026-09-02T01:54:46Z` but its job did not start + until `2026-09-03T01:17:10Z`** — a **23-hour-22-minute queue wait** before it even began running, then + ran for ~14 minutes and was cancelled (superseded by this same investigation's live re-check, not by a + bug). +- The paired "Required OpenCode Review" run for the identical SHA (`33581213805`), created at the same + timestamp, **was still `status: queued`, `conclusion: null` when re-checked live on 2026-09-03** — stuck + queued for **24+ hours with no job started.** +- Six separate "PR Governance" workflow runs fired for this one unchanged SHA (five `pull_request_target` + events, one `pull_request_review`). Investigated further after a peer session flagged this as a likely + redundant-trigger source: `naruon`'s `pr-governance.yml` and `scripts/ci/pr_governance_gate.sh` were + fetched and read in full (not assumed). Two corrections to the initial framing: (1) the `governance` job + carries a job-level `if:` that restricts its `check_run`-triggered case to CodeRabbit-named checks only + — GitHub Actions genuinely cannot filter `check_run` by name at the `on:` trigger level, but the job + itself is *skipped* (no runner requested) for every non-CodeRabbit check-run completion, so that specific + vector is not the job-slot waste it first appeared to be; (2) the five observed `pull_request_target` + firings on one unchanged SHA came from non-`synchronize` events — `synchronize` is the only + `pull_request_target` type tied to a new commit, and the SHA never changed. The specific event types were + not verified (an earlier draft attributed them specifically to `labeled`/`unlabeled`, which is one + plausible explanation among several non-`synchronize` types and was not confirmed against the PR's actual + event history — corrected per Devin Review). More importantly, `pr_governance_gate.sh` evaluates **live** state at the current head on every + run (required-check states via `gh pr checks`, unresolved review-thread count, CodeRabbit findings via + check-runs and commit status) — it is explicitly not a pure function of `(head_sha, base_sha)`, so a + same-head debounce ("skip if nothing changed since the last run at this SHA") would be actively wrong: it + could leave the gate reporting a stale blocker list from before a required check finished or a review + landed, a real correctness regression in merge-gating, not merely a missed optimization. No fix was + attempted for this reason — a safe one needs either confirming which specific labels toggled five times + on this PR and whether they are governance-irrelevant, or a considered design for distinguishing genuinely + new gate-relevant information from a redundant re-trigger. Recorded as still open, not fixed. + +**Precision on what this evidence actually establishes (Devin Review):** the 23h22m and 24+ hour waits prove +queueing occurred; on their own they do not prove a plan-level concurrent-job ceiling is the *exclusive* +cause, only that they are consistent with one. `docs/doctoring/actions-plan-concurrency-ceiling-20260903.md` +treats its own live API counts (jobs `in_progress` vs. `queued`) the same way — as corroboration for that +theory, not as independent proof of it; that record does not claim otherwise, and neither does this one. A +misconfigured scheduler, a starved runner label (a real, separately-documented org history — see this +repository's own `ubuntu-latest` floating-image finding), or some other single-repository cause could in +principle also produce a multi-hour wait for one PR. What narrows toward capacity *here*, specifically, is +that Result 1 above already verified three of the four central workflows' cancellation/scheduling logic is +fully correct, and that the fourth's (`noema-review.yml`'s) confirmed bug has a different failure signature +than what this evidence shows: that bug wrongly *cancels* a still-current run outright, whereas Result 2's +runs sat *queued* for 23h22m/24+ hours with no cancellation at all. A run stuck queued that long, never +cancelled, is not the symptom the confirmed bug produces — so this specific wait is still not explained by a +known bug in this PR's own review pipeline, which narrows the remaining explanation toward capacity rather +than proving it by elimination of every other conceivable cause. + +With that precision stated, this evidence is consistent with, and corroborates, the root cause +`docs/doctoring/actions-plan-concurrency-ceiling-20260903.md` already identified (a plan-level concurrent-job +ceiling) — now with a concrete, individually named example instead of only aggregate counts: a real open +PR's real review evidence sat queued for over a day, with no workflow-configuration defect found to explain +it. This strengthens, rather than changes, that record's conclusion and its recommendation (a plan-tier +decision or added runner capacity is the actual fix; workflow-file consolidation reduces total triggered +runs at the margin but cannot lift the ceiling). + +## What this resolves, and what it does not + +- **Resolves:** whether item 13's specific "no cancellation on push" complaint reflects a real + configuration bug *as evidenced by its own cited example* (`ContextualWisdomLab/naruon#1528`). It does not — that PR + never exhibited a multi-SHA race; see Result 2. Item 13 should be marked accordingly in + `docs/product-technical-gap-baseline.md`, alongside the confirmed finding below rather than instead of it. +- **Confirmed finding, fix proposed but not yet merged (raised by Devin Review, adversarially re-verified + twice with no refutation found):** `noema-review.yml`'s native `cancel-in-progress` can cancel a genuinely + current run when GitHub processes an older push's `synchronize` event after a newer one — GitHub does not + guarantee webhook/dispatch delivery order, and this workflow's concurrency group has no head-SHA component + to make such an inversion harmless. See the corrected caveat under Result 1's table for the full mechanism + and the corroborating evidence that `strix.yml` and `opencode-review.yml` both deliberately avoid this + exact pattern already. **Fix pushed as commit `31e46db` on `ContextualWisdomLab/.github#1661`** (a peer + session ported `opencode-review.yml`'s own `#1568` fix: the event's head SHA added as a third group-key + segment), independently re-verified against that branch — but `31e46db` is not reachable from `main` + (`git compare main...31e46db` reports `diverged`, `#1661` still open), and `main`'s live `noema-review.yml` + still has the pre-fix group with no head-SHA component. Do not mark this closed on `main` until `#1661` + merges — the same "proposed vs. landed" distinction Devin caught once already on this record's sibling PR + (`.github#1765`'s phase-labeling citation). +- **Open, unverified lead, not a finding:** whether naruon's `pr-governance.yml` fires more often than + necessary per PR (six runs on one SHA in this one case) is worth a dedicated, evidence-first follow-up + investigation of that PR's actual label/review event history before concluding anything — recorded here + so it is not lost, not asserted as confirmed. +- **Investigated and refuted (raised by Devin Review, adversarially re-verified with no refutation found):** + a claim that `strix.yml`'s `pull_request_target: paths-ignore:` list suppresses `cancel-superseded-pr-runs` + (a job in the same file, sharing the same trigger) for a push whose diff touches only ignored paths, + leaving the previous head's Strix scan running indefinitely. `strix.yml`'s own internal gap is real — that + half of the claim is correct, and there is no escape hatch inside that file. But a sibling required + workflow, `pr-review-merge-scheduler.yml`, has no `paths-ignore` at all and fires unconditionally on the + same event; its `scan-pr-queue` job unconditionally calls `cancel_stale_pr_runs()` + (`scripts/ci/pr_review_merge_scheduler.py`), which cancels any active run in the repository whose + `head_sha` no longer matches the PR's live head — regardless of which workflow created that run — + typically within the same push event, with a 30-minute local-cron backstop specifically for + `ContextualWisdomLab/.github` (whose own comment already documents this as the reason `org-queue-sweep`'s + `.github` exclusion is safe) and an hourly org-wide sweep backstop for every sibling repository. The + scenario does not leave a stale Strix scan running indefinitely anywhere. +- **Bypass-merge authorization:** the user authorized bypass-merge for this investigation as a genuine + chicken-and-egg case. It is not used here because no fix was found that needed it for item 13's own + hypothesis or the paths-ignore claim; the one confirmed bug found (`noema-review.yml`'s concurrency + ordering hazard, above) is deliberately left for its own dedicated fix PR rather than bypass-merged in + alongside documentation. This record is itself a normal docs-only PR, subject to normal review like any + other. + +## Audit trail + +**Devin Review correctly flagged that the two run IDs below are not durable, externally checkable evidence +on their own.** `wf_eb15dd2b-ad1` and `wf_68f78449-bb6` are internal Claude Code orchestration-tool run +identifiers, local to the session that produced them — they have no repository path, no public URL, and no +way for a future reader (human or agent) to open and inspect them. They are recorded here only as an +internal audit trail of *how* this record's investigation was structured (agent counts, investigate-vs-verify +split), not as the evidence itself. The actual checkable evidence is what each finding above cites inline: +exact file paths and line ranges in this repository, `raw.githubusercontent.com` fetches of the live +workflow files, `gh api` calls against the GitHub REST API (rulesets, runs, jobs, PRs), and named PR/commit +references (`#1568`, `ContextualWisdomLab/naruon#1528`). Any future reader who doubts a finding above should +re-run those same file reads and API calls, not attempt to open these run IDs. + +- Workflow run `wf_eb15dd2b-ad1` (9 agents: 4 investigate, 1 direct-evidence pull, 4 adversarial verify) — + internal orchestration record only, per the caveat above. +- Workflow run `wf_68f78449-bb6` (4 agents: 2 investigate, 2 adversarial verify) — the follow-up + investigation of the two substantive Devin Review findings above (`noema-review.yml`'s confirmed + concurrency bug, `strix.yml`'s refuted paths-ignore claim); internal orchestration record only, per the + caveat above. +- `docs/doctoring/actions-plan-concurrency-ceiling-20260903.md` — the root-cause record this evidence + corroborates. +- `docs/product-technical-gap-baseline.md` — backlog item 13's original text and citation, to be updated + to reference this record's verdict. diff --git a/docs/doctoring/loop-brief-items-15-18-verification-20260903.md b/docs/doctoring/loop-brief-items-15-18-verification-20260903.md new file mode 100644 index 0000000000..ebd89839fd --- /dev/null +++ b/docs/doctoring/loop-brief-items-15-18-verification-20260903.md @@ -0,0 +1,205 @@ +# Loop-brief items 4, 15-18, 38, 39: verified already resolved, no further change needed + +## Context + +The 2026-09-03 standing-loop brief asked to confirm whether several specific +workflow-consolidation and telemetry items were complete, since the queue felt +like it was growing rather than shrinking. This records what was checked and +why each item needed no further code change as of this branch's base commit +(`4f95abc`). + +## Items 4 / 39 — opaque 900-second Noema "Repair" timeout, no telemetry on why + +Reproduced from the linked evidence: +`ContextualWisdomLab/html4tree` run `33560972491`, job `100033086428` +("Required Noema Review ...#595"), step 13 "Prepare Noema model verdict" +failed with `NoemaRepairDeadlineExceeded: Noema repair exceeded 900-second +absolute wall-clock deadline` on 2026-09-02T02:28 UTC — no further specifics, +matching the complaint exactly. The item-39 example +(`contextual-orchestrator` run `33580381913`, ContextualWisdomLab/contextual-orchestrator#1008) is the same class of +failure, same day. + +Already fixed on this branch's base, same day: PR (`a28fc2f`, +"fix(noema): remove caller repair deadline and duplicate model call") found +the 900-second bound had "no owner-specified or measured basis" and, deeper, +that Noema was duplicating a repair/failover responsibility +`contextual-orchestrator` already owns — turning one gateway failure into two +expensive calls. The fix: Noema now sends exactly one structured-output +request to the gateway, with no caller-side deadline, retry, or temperature; +every gateway call now emits a passive Actions annotation carrying attempt +count, elapsed duration, active phase, and a sanitized serving-model +identifier (see `docs/doctoring/noema-repair-attempt-telemetry.md`, PR +`86ef3e7` for the doc's own later clarification pass). A permanent contract +test (`tests/test_noema_repair_has_no_fixed_wall_clock_deadline.py`) forbids +`NOEMA_REPAIR_DEADLINE_SECONDS`, `NoemaRepairDeadlineExceeded`, +`signal.setitimer`, and a caller-authored retry/temperature from ever +reappearing; ran it plus `tests/test_noema_repair_attempt_telemetry.py` +locally (25 passed) to confirm it holds on this branch. + +The item-39 PR (ContextualWisdomLab/contextual-orchestrator#1008, head `f35ee58d`) is still +`mergeable_state: blocked`, but its Noema check now shows a fresh attempt +queued at `2026-09-02T19:32:21Z` — after the fix merged — sitting `queued` +with no conclusion yet. That is the already-documented org-wide Actions +job-queue ceiling (#1754), not a recurrence of the repair-deadline bug; no +separate action taken here. + +## Item 38 — auto-PR CodeQL into every new repository + +Checked whether new repositories actually get CodeQL coverage, and how. Two +mechanisms exist, deliberately not overlapping: + +- GitHub's native org-level "code scanning default setup" (org code-security + configuration id `17`, "GitHub recommended") is attached to exactly 3 + repositories: `noema`, `feelanet-adfs`, `pg-llm-batch` + (`gh api orgs/ContextualWisdomLab/code-security/configurations/17/repositories`). + `noema` needs this because it is one of the ruleset's own exclusions below. +- The org required-workflow ruleset (`18156473`) requires `codeql-pr.yml` + (among others) on `repository_name: {include: ["~ALL"], exclude: ["noema", + ".github", "IRT-bibliography-set"]}` — `~ALL` is a *dynamic* match, so a + brand-new repository is covered from its very first pull request with zero + manual or automated action, the moment that PR exists. `.github` runs + `codeql-pr.yml` directly on its own `pull_request` trigger instead of via + the ruleset (excluding a ruleset's own source repo from being its own + target avoids a self-referential double-trigger). `IRT-bibliography-set` + has neither mechanism, consistent with its name suggesting a non-code data + repository CodeQL would not apply to anyway. + +The `~ALL` dynamic-target mechanism is a better answer than a bot-authored +PR *when it actually fires* — but it didn't always. Devin's review on this +PR correctly caught that `codeql-pr.yml`'s own `on: pull_request: branches: +[main, master, develop]` filter is a second, narrower gate underneath the +ruleset's dynamic target, and it silently produced **zero** CodeQL checks for +a repository whose default branch has a different name. Verified live before +the review comment arrived at concluding text: `j-planner` (default branch +`gh-pages`, real open PR #2 as of this writing) received every other +required check — `opencode-review`, `noema-review`, `strix`, the +`security-scan.yml`-bundled `osv-scan`/`trivy-fs`/`scorecard`/`Semgrep +OSS`/`dependency-review` (that workflow deliberately has no branch +restriction, "Do not restrict the base ref" per its own comment) — but not +one `Detect CodeQL languages` or `Analyze (...)` check of any kind. Three +additional org repositories (`argos`, `OmniRoute`, `graphify` — all forks, +default branches `developmental`, `release/v3.8.50`, `v8` respectively) were +equally exposed. + +**Fixed**, not just documented: removed the `branches: [main, master, +develop]` restriction from `codeql-pr.yml`'s `pull_request` trigger, matching +`security-scan.yml`'s own established "do not restrict the base ref" +precedent — the ruleset's `ref_name: ["~DEFAULT_BRANCH"]` condition is +already the authoritative gate for which branch qualifies, so the workflow's +own hardcoded list was pure redundant risk, not a second layer of intended +protection. Updated the one contract-test assertion that pinned the old +line (`tests/test_codeql_pr_workflow_contract.py:19`); the workflow's other +17 assertions, the CodeQL-action-version-pin test, and the SARIF-gate +behavioral test all still pass, `actionlint` reports no errors, and the file +still parses as valid YAML. + +**Not fixed here** (Devin's second, independent catch, correct but out of +this PR's scope): the language-detection matrix in the same workflow only +recognizes GitHub Actions, JavaScript/TypeScript, Python, and Java/Kotlin — +CodeQL also supports C/C++, C#, Go, Ruby, and Swift, none of which this +matrix detects; a repository containing only one of those falls back to +scanning `actions` alone rather than its real source. That is a larger, +separately-scoped change (new per-language file-detection heuristics plus +matching contract-test coverage) rather than a one-line fix, and is tracked +as a follow-up rather than rushed into this PR. + +## Item 15 — remove `org-queue-sweep` if plain GitHub Actions syntax can do it + +`org-queue-sweep` (`.github/workflows/pr-review-merge-scheduler.yml:591`) walks +every organization repository looking for PRs that became mergeable after +their last triggering event fired (event-driven scheduler runs do not retry on +their own). GitHub Actions has no native primitive for "enumerate every org +repository's PR queue and act on each" — this requires the GitHub API calls +the job already makes; it is not something a `schedule:`/`concurrency:` block +alone could replace. + +What plain Actions syntax *can* control, it already does: the schedule trigger +is deduplicated by workflow's own top-level `concurrency:` group +(`schedule-${{ github.event.schedule }}`), and the job carries a `timeout-minutes: 60` +ceiling plus several already-hard-won budget knobs +(`ORG_SWEEP_MAX_PRS`, `ORG_SWEEP_REVIEW_DISPATCH_LIMIT`, +`ORG_SWEEP_MAX_UNAVAILABLE`, rotation logic) whose comments cite the specific +production incidents that shaped them (#1219, #1223). + +"Rate limit" covers at least two distinct resources here, and this item's +"rate limit issues" symptom should not be collapsed into one cause: + +- The org's Actions **plan-level 60-concurrent-*job*** ceiling (#1754, + docs-only, merged) — a billing-tier constraint on how many jobs (of any + kind, any repo) can run at once. This is the one that best matches the + general "queue piles up instead of shrinking" symptom this loop-brief + opened with, and no workflow-file change can fix it. +- A separate, already-documented **LLM-provider rate limit** — a + `litellm.RateLimitError` storm against the shared NVIDIA NIM key from too + many *concurrent Strix/review callers* (`.github` PR #1297, 2026-08-23/24; + see `.github` PR #1661 / + `docs/doctoring/strix-cross-pr-concurrency-starvation-20260902.md`, not yet + merged to `main`). That is why `strix.yml`'s scan job deliberately + serializes per repository instead of per PR — a different mechanism, a + different resource, and not something `org-queue-sweep` itself triggers + directly (it can *dispatch* reviews, but it does not call an LLM provider + on its own). + +`org-queue-sweep`'s own GitHub REST calls are subject to a third resource +(GitHub's per-token API rate limit), which is why it already paginates +conservatively and fails closed past `ORG_SWEEP_MAX_UNAVAILABLE` rather than +retrying harder. Two of the three resources already have a workflow-level +mitigation in place today (`strix.yml`'s per-repository serialization for the +LLM-provider limit; `org-queue-sweep`'s own pagination/budget ceilings for +its GitHub API calls) — this item is asking whether a *further* edit is +needed, not claiming no edit exists. Only the plan-level 60-job ceiling is +structurally outside any workflow file's reach, since it caps total +concurrent jobs org-wide regardless of how any single workflow is written. +No action taken; removing or rewriting `org-queue-sweep` would re-litigate an +already-evidenced design without touching any of the three resources. + +## Item 16 — consolidate the per-repo hourly-review-repair caller shown in the linked run + +The linked run (`ContextualWisdomLab/.github` run `33524178483`, job +`99910668839`, workflow `governance-risk-compliance-hourly-review-repair.yml`) +failed at "Validate scheduler target and dispatch authority" because +`governance-risk-compliance` was hardcoded into the scheduler in a way the +validator rejected. Both problems are already fixed on this branch's base: + +- The per-repo caller file itself no longer exists — consolidated into the + shared `hourly-review-repair.yml` matrix by PR #1673 + (`29b931e`, "refactor(actions): consolidate hourly review-repair callers"). +- The hardcode that made that specific run fail was replaced with an + org-variable admission path by PR #1743 (`8c08583`, already at the tip of + `main` this branch is based on; doctoring: this commit's own message and + `4f95abc`). + +No action taken; the cited failure predates both fixes. + +## Item 17 — maximize GitHub Actions file consolidation org-wide + +Already swept: `docs/doctoring/ci-workflow-duplication-audit-20260902.md` +(PR #1731, `9330d41`) re-checked all 63 non-archived/non-fork org repositories +(255 workflow files) for duplication beyond the hourly-review-repair, +R-CMD-check, and dependency-review consolidations already completed. Verdict: +18 of 19 filename-collision groups are genuinely different policies (different +language/toolchain, security posture, thresholds, trust model, or job +topology — evidenced per group), and the one true near-duplicate +(`hourly-pr-maintenance.yml` in DiagramWeave/ThreadWeave) is already two +~20-30 line thin callers of a shared reusable workflow, differing only by a +deliberate cron stagger — wrapping that further would be an unrequested +abstraction over two already-small files. No action taken; re-running this +audit from scratch would duplicate #1731 rather than extend it. + +## Item 18 — GitHub App installation token format change (`ghs_...`, ~520 chars, stateless) + +Searched every `.py` and `.sh` file under `scripts/ci/` and `.github/` +(workflows, and the one composite action at +`.github/actions/orchestrator-free-sidecar/action.yml`), then re-checked the +whole repository tree (this repo has no `.yaml`-suffixed files, and +`opencode.jsonc` and the pinned `requirements-*.txt` files carry nothing +token-shaped either), for any assumption about installation-token length or +prefix shape: no fixed-length checks (`len(token) == N`, `token[:N]`), no +prefix/length regexes matching the old `ghs_` format, and no truncating +display logic keyed to a specific length. The only token-shaped regexes +present (`noema_review_gate.py:240,245`, `pr_review_merge_scheduler.py:254`) +are secret-redaction patterns (`token\s+` -> `***`) +that mask a token of any length or format when logging — they do not depend +on the token being any particular size. No action taken; this repository has +nothing that would break under the announced longer, stateless +installation-token format. diff --git a/docs/doctoring/model-workflow-native-concurrency-runtime.json b/docs/doctoring/model-workflow-native-concurrency-runtime.json new file mode 100644 index 0000000000..eff7246d6b --- /dev/null +++ b/docs/doctoring/model-workflow-native-concurrency-runtime.json @@ -0,0 +1,23 @@ +{ + "pull_request": 1855, + "initial_document_head": "641297d3ef60d8914a1cbfbab51c980c824c45bc", + "initial_runs": { + "noema": 33871580217, + "opencode": 33871580244, + "strix": null + }, + "first_full_model_head": "b1c353ecf31978c98251b22640ecd89d17d46c20", + "first_full_model_runs": { + "noema": {"run_id": 33871687610, "conclusion": "cancelled"}, + "opencode": {"run_id": 33871687602, "conclusion": "cancelled"}, + "strix": {"run_id": 33871687583, "conclusion": "cancelled"} + }, + "cancelling_head": "9c7fa72d9d6b20ed43c1e8d886b5c3d14a5add25", + "cancelling_head_runs": { + "noema": 33871729756, + "opencode": 33871729781, + "strix": 33871729820 + }, + "observed_result": "All first_full_model_runs reached conclusion=cancelled while the cancelling head remained queued.", + "note": "The initial Markdown-only head was intentionally excluded by Strix path filters." +} diff --git a/docs/doctoring/model-workflow-native-concurrency-runtime.md b/docs/doctoring/model-workflow-native-concurrency-runtime.md new file mode 100644 index 0000000000..619ecedffe --- /dev/null +++ b/docs/doctoring/model-workflow-native-concurrency-runtime.md @@ -0,0 +1,9 @@ +# Model workflow native concurrency runtime proof + +This probe records two successive pull-request heads created after central +workflow-level concurrency shipped in `.github` PR #1854. The first head +establishes Strix, OpenCode, and Noema runs under the new group contract; the +second head records whether GitHub natively cancels those superseded runs. + +The expected group shape is `-ContextualWisdomLab/.github-`. +Different workflows, repositories, and pull requests remain independent. diff --git a/docs/doctoring/noema-model-output-repair-boundary.md b/docs/doctoring/noema-model-output-repair-boundary.md index d1602f92de..88635d01a2 100644 --- a/docs/doctoring/noema-model-output-repair-boundary.md +++ b/docs/doctoring/noema-model-output-repair-boundary.md @@ -1,33 +1,41 @@ # Noema model-output repair boundary -## Incident +## Current contract (2026-09-02) -On 2026-09-01 the required Noema review for `ContextualWisdomLab/naruon#1505` reached deterministic verdict validation, rejected an adversarial-probe `outcome` outside the closed `falsified|confirmed` domain, then spent the repair path on a long second model call that ultimately surfaced only `HTTP 502 Bad Gateway`. That final transport symptom erased the more informative first trusted-validator failure from the top-level diagnostic. +`.github` owns pull-request review orchestration, exact-head evidence, deterministic verdict validation, and publication. `contextual-orchestrator` owns provider discovery, capability routing, the `orchestrator/free` pool, structured-output repair, failover, and provider completion. -## Decision +After `.github#1672` merged as `a28fc2f4e185df7847e2f2f5f6ec561d1e84805d`, Noema issues exactly one structured-output request for a review. The repository caller no longer performs a second model repair request and no longer installs a 900-second process-level repair deadline. There is no caller-owned fixed inference wall-clock deadline or sampling-temperature override; gateway/provider completion and the outer workflow lifecycle remain separate concerns. -1. Model-produced JSON/envelope/schema/semantic-contract failures are `NoemaModelOutputError`; they remain fail-closed and are not consumer-source findings. -2. The primary review keeps the accepted contextual-orchestrator no-fixed-inference-timeout contract. The *single corrective attempt* is different: it repairs an already-completed verdict and therefore has one 900-second process-level wall-clock deadline across open/read/decode/validation. It deliberately does not use `urllib`'s renewable socket-operation timeout. -3. A corrective transport failure is `NoemaTransportError` and carries the sanitized first validator diagnostic plus the later transport exception class/status. Raw model output is never copied into public Actions diagnostics. -4. Exact-head validation before retry and before publication remains mandatory. All model traffic remains on contextual-orchestrator `orchestrator/free`. +The gateway response is still validated locally. A malformed or semantically invalid response fails closed with a bounded diagnostic containing the phase, elapsed duration, stable failure category, and served-model metadata when available. Raw model output and credentials are not written to Actions logs. + +## Historical incident and the 900-second distinction + +On 2026-09-01, `ContextualWisdomLab/html4tree` reached the old Noema corrective path after malformed JSON. The old caller then reported `NoemaRepairDeadlineExceeded` after a 900-second absolute wall-clock boundary. That boundary belonged to the superseded caller-side repair implementation; it is not a current Noema inference policy. + +The same incident family also exposed real upstream failures: HTTP 413 `request_too_large`, Bytez discovery HTTP 500, NVIDIA timeout/429/404 responses, and malformed structured output. These are different failure classes and must remain visible as separate telemetry events rather than being collapsed into a generic timeout. + +Three `timeout --kill-after=20 900` commands remain in `opencode-review-dispatch.yml`. They cap individual untrusted test-measurement shell commands in the coverage evidence job. They are not model requests, not Noema repair, and not a 900-second GitHub job timeout. Operational logs should describe them as sandbox command containment (for example, `sandbox_command_limit_seconds=900`) so an operator cannot mistake them for inference termination. + +## Diagnostic and concurrency invariants + +1. Model-produced JSON, envelope, schema, and semantic-contract failures remain fail-closed and are not consumer-source findings. +2. Every provider attempt reports a phase such as connecting, reading, decoding, or validating, its elapsed duration, a stable failure category, and the served model if known. Provider status classes such as 413, 429, 500, and 502 are retained as categories without copying provider secrets or raw model output. +3. The triggering pull-request head is checked before model work and again before publication. A push to the same PR makes the old head obsolete; the old run must not publish a verdict or spend a second repair call. +4. All model traffic for required review remains on contextual-orchestrator `orchestrator/free` and is subject to its discovery, capability, failover, and privacy policy. +5. A workflow shell timeout is evidence about that shell command only. It must never be used as evidence that the gateway or provider ended inference. ## Verification -The #1617 regression first proved RED because `NoemaModelOutputError` did not exist. The repair adds focused cases for malformed-verdict typing, malformed-then-502 evidence preservation with the 900-second repair-only timeout, and repeated malformed output remaining typed and non-passing. The repository full coverage/docstring gate is run before the one-shot repair workflow commits the result. +The merged #1672 regression suite proves one gateway request, no caller-side retry/deadline/sampling machinery, sanitized model telemetry, strict local validation, bounded trailing-comma normalization, and exact changed-line diagnostics. A fresh exact-head Actions run is still required to establish hosted runtime evidence; queued or cancelled checks do not count as a pass. -## References +Incident replay acceptance requires the log to distinguish at least: request_too_large, discovery_failure, rate_limited, provider_transport, malformed_model_output, stale_head, and sandbox_command_timeout. Each category must include phase and duration, while raw response bytes, credentials, and unbounded provider text remain excluded. -Fielding, R., Nottingham, M., & Reschke, J. (2022). *HTTP semantics* (RFC 9110). Internet Engineering Task Force. +## References -Python Software Foundation. (2026). *urllib.request — Extensible library for opening URLs*. Python 3 documentation. +Fielding, R., Nottingham, M., & Reschke, J. (2022). HTTP semantics (RFC 9110). Internet Engineering Task Force. +Python Software Foundation. (2026). urllib.request — Extensible library for opening URLs. Python 3 documentation. ## Actionable diagnostic boundary -Corrective prompts need the deterministic *class* of a malformed verdict to repair it, -but do not need arbitrary model-produced values. Trusted structural validator messages -(such as a missing required field or an invalid adversarial-probe outcome class) remain -available after secret scrubbing. Unsupported decision values and unknown model-output -text are redacted to stable diagnostics, and a repeated invalid-model exception is raised -without retaining the raw model exception as an explicit cause. Tests use a sentinel value -to prove it reaches neither the retry prompt nor the final diagnostic. +Corrective prompts, when implemented by the gateway, may use the deterministic class of a malformed verdict but do not need arbitrary model-produced values. Trusted structural validator messages remain available after secret scrubbing. Unsupported decision values and unknown model-output text are represented by stable diagnostics, and raw model exceptions are not retained as public causes. \ No newline at end of file diff --git a/docs/doctoring/noema-repair-attempt-telemetry.md b/docs/doctoring/noema-repair-attempt-telemetry.md new file mode 100644 index 0000000000..ee4d681a59 --- /dev/null +++ b/docs/doctoring/noema-repair-attempt-telemetry.md @@ -0,0 +1,34 @@ +# Noema single-request review incident and telemetry contract + +## Incident + +On 2026-09-02, a required Noema review reported only a caller-owned 900-second repair deadline after a malformed structured response. The bound had no owner-specified or measured basis and conflicted with ADR-0003: model inference and repair verdict calls do not carry repository-authored fixed wall-clock deadlines. + +```text +initial malformed structured response -> repository repair request -> fixed 900-second abort +``` + +The later review established a second ownership error: `contextual-orchestrator` already owns structured-output validation and its governed repair/failover. Issuing another repository-side model request duplicated that policy and could turn one gateway failure into two expensive calls. + +## Final executable contract + +Noema now sends exactly one structured-output request to the configured gateway. GitHub Actions fixes the model alias to `orchestrator/free`; the caller declares no provider, paid fallback, sampling temperature, or fixed inference timeout. `contextual-orchestrator` owns provider discovery, capability routing, structured-output repair, failover, and upstream completion. The repository remains responsible for deterministic local validation and exact-head publication. + +Every gateway call emits exactly one passive Actions annotation. Success and failure annotations include caller attempt count, elapsed duration, active phase (`connecting`, `reading`, `decoding`, or `validating`), and a best-effort serving-model identifier. Serving-model text is secret-scrubbed, control-character-normalized, UTF-8 printable, and bounded before it can reach an annotation. Raw model output is never logged. + +The local trailing-comma parser remains a deterministic syntax transform only. It may remove a genuine trailing comma after a complete JSON value, but missing-value forms such as `[,]`, `{,}`, `[1,,]`, and `{"a":,}` remain invalid. The transform emits no second attempt-level annotation and never bypasses semantic verdict validation. + +Exact changed-line diagnostics include the rejected path/line/side, an unambiguous array position, and a bounded nearest-line hint. This keeps a failed verdict repairable at the gateway without expanding the output contract to one record per changed line. + +## Ownership and failure scenes + +```text +Noema workflow -> local contextual-orchestrator sidecar -> orchestrator/free -> routed free candidate + -> one returned envelope -> local deterministic validation -> exact-head publication +``` + +If the gateway cannot produce a valid structured verdict, Noema fails closed after that one caller request. If the PR head moves during model work, the post-call exact-head check discards the stale verdict. If telemetry carries hostile model identifiers, annotation sanitization prevents CR/LF or surrogate data from becoming workflow commands or crashing the runner. + +## Verification + +The permanent contract test forbids `NOEMA_REPAIR_DEADLINE_SECONDS`, `_repair_wall_clock_deadline`, `NoemaRepairDeadlineExceeded`, `signal.setitimer`, retry-only parameters/recursion, and caller-specified `temperature`. Focused regressions prove one request on success and failure, one annotation per attempt, safe serving-model telemetry, strict missing-value rejection, accepted genuine trailing commas, and preserved exact changed-line diagnostics. diff --git a/docs/doctoring/noema-review-failure-retrospective-and-improvement-plan-20260903.md b/docs/doctoring/noema-review-failure-retrospective-and-improvement-plan-20260903.md new file mode 100644 index 0000000000..ca30b964e9 --- /dev/null +++ b/docs/doctoring/noema-review-failure-retrospective-and-improvement-plan-20260903.md @@ -0,0 +1,209 @@ +# Doctoring record: Noema review-gate failure retrospective and improvement plan (2026-09-03) + +- **Date:** 2026-09-03 +- **Subject:** backlog item 23 — "noema의 리뷰 실패 사례를 다시 취합해서 개선안을 도출 바람" (re-aggregate Noema's + review-failure incidents and produce an improvement plan). The raw material already existed, scattered + across 18 individual records; this record is the first pass at pattern extraction and concrete next steps. +- **Decision record:** none in `docs/adr/` yet — this record proposes candidate ADR-worthy changes in + "Improvement plan" below rather than deciding them unilaterally. +- **PR:** see the PR that carries this commit. + +## Method + +Read all `noema-review-gate` incident sections in `docs/product-technical-gap-baseline.md` (7 sections +dated 2026-08-31), all Noema-specific `docs/doctoring/` records (6 files), and every GitHub issue whose +title names Noema's review-gate failure modes (5 issues: 3 open, 2 closed) — full text of each, not just +titles. Grouped by root-cause shape rather than by date, since several incidents on the same date share one +underlying mechanism. + +## The 18 incidents, grouped by root-cause shape + +### Shape 1: crash-before-repair-boundary (4 incidents) + +`call_llm` in `scripts/ci/noema_review_gate.py` has one repair-retry path: a malformed verdict gets one +bounded correction request before failing closed. Every incident in this shape is the *same* underlying +defect — code that runs *before* that repair boundary is unguarded, so a specific input shape crashes the +whole required check with a raw traceback instead of reaching the repair path at all. + +1. **Malformed JSON envelope** (`.github#1507`, gap-baseline 2026-08-31 #1) — `extract_json_object`'s + `json.loads()` had no exception handling; an unquoted property name mid-object raised + `json.JSONDecodeError` past the module's `except RuntimeError` guard (which only catches + `RuntimeError`), crashing every PR org-wide that hit this LLM-output edge case. +2. **Non-UTF-8 gateway reply** (`.github#1507` round 3, gap-baseline 2026-08-31 #3) — the *identical* + shape, one step earlier: `response.read().decode("utf-8")` sat before the `try`, so invalid UTF-8 bytes + raised `UnicodeDecodeError` before `extract_llm_message_content` or the repair boundary ever ran. +3. **Truncated structured completion** (`.github` issue #1596, closed via a merged fix) — a response cut + off mid-JSON (provider truncation, not malformed content) hit the same unguarded-preamble shape. +4. **Invalid changed-line citation exhausting the full retry budget** (`.github` issue #1613, **still + open**) — a variant one layer up: the *repair* path itself has no cap distinguishing "wrong citation, + retry once" from "wrong citation every time, stop burning budget," so a bad citation can consume the + entire multi-hour LLM budget instead of failing closed early. + +**Pattern:** every fix in this shape was scoped to the *one* input shape a reviewer happened to report +(malformed JSON → fixed; non-UTF-8 → found and fixed one round later; truncation → a separate issue). None +of the three fixes generalized to "guard every byte- and structure-level transformation of the raw HTTP +response before the repair boundary" as a single invariant, which is why the same shape kept resurfacing +one layer at a time rather than being closed once. + +### Shape 2: a fix for one class of bug introduces a different bug (2 incidents) + +5. **Fail-closed fix itself leaked a secret to a public log** (`.github#1507` round 2, gap-baseline + 2026-08-31 #2) — the malformed-JSON fix (shape 1, incident 1) logged the LLM's raw response text through + `scrub_sensitive_data`, a finite regex-based scrubber, into a `RuntimeError` message that `pull_request_target`'s + public Actions log then printed via `::error::{exc}`. A regex allowlist of *known* secret shapes cannot + bound what an LLM might echo back in an *unrecognized* shape — closing the crash opened a + secret-disclosure path. Fixed by removing the raw/scrubbed text from the log entirely, replacing it with + a length + truncated SHA-256 fingerprint (enough to correlate repeats, nothing to leak). +6. **The live-head re-check added to close a cancellation gap was itself an unguarded API call** + (gap-baseline 2026-08-31, "the live-head re-check added to close the above gap...") — a directional + cancellation guard's own re-verification step (`gh api ... --jq '.head.sha'`) was a bare assignment + under `set -euo pipefail`, unlike every sibling `gh api` call in the same file. A transient rate-limit or + network blip on *that one call* failed the entire `noema-review` job over a housekeeping hiccup unrelated + to the actual review. + +**Pattern:** both incidents are the direct product of *not applying the same defensive-coding standard the +surrounding code already uses* when writing new code (existing `gh api` calls in the same file already +wrapped failures in `if ! ...; then warn; continue/return; fi` — the new one just didn't copy that pattern; +existing repair-path logging already understood raw model output as untrusted — the new log line reused an +old, insufficient scrubbing tool instead of re-deriving "should this be logged at all"). + +### Shape 3: race-condition guards, each independently reimplemented, each independently buggy (5 incidents) + +Noema's "is the run I'm about to act on still the live/current one" check exists in at least four separate +places in `noema-review.yml` / `noema_review_gate.py`, written at different times, each with its own bug: + +7. **`workflow_run`-triggered reviews always looked stale** — the stale-trigger guard's `EXPECTED_HEAD` + read `github.event.workflow_run.head_sha`, but GitHub's `workflow_run` payload for a + `pull_request_target`-triggered parent carries a different head field than the guard assumed, so every + `workflow_run`-path review self-aborted as "stale" even when current. +8. **Case-sensitive SHA comparison** (same guard, same incident record) — a second bug in the identical + guard: SHA comparison wasn't case-normalized, so a case variation (rare but real, e.g. from a different + API surface's casing convention) would also false-positive as stale. +9. **Bare `head_sha` match let one PR's close cancel a different PR's still-needed run** + (`cancel-closed-pr-runs` job) — the cancellation selector's match condition was underspecified (an OR of + three clauses without enough scoping), so closing PR A could cancel a review run that actually belonged + to PR B if they happened to share a head SHA shape. Fixed independently by a concurrent session + (`e0f542f`) while this investigation was in progress — a real example of the org's concurrent-session + model working as intended (fetched, verified, extended rather than force-pushing a competing fix). +10. **Repair-retry fired without re-checking a live-moved PR head** — `inspect_and_review` checks + `expected_head` against the PR's live head twice (before any model work, and again before + `submit_review`), but `call_llm`'s *internal* self-recursive repair-retry branch had no `expected_head` + parameter at all and no check of its own — a PR head moving mid-first-attempt could burn a second, + potentially multi-hour LLM call producing a verdict the outer check was always going to discard anyway. + (Correctness was never at risk — the outer check still caught it — but compute was wasted silently, + every time this raced.) +11. **`workflow_run` head misread inside `opencode-review.yml`'s verdict poller** — a sibling, structurally + identical guard in the *OpenCode* review poller (not Noema, but the same "which head is live" question, + included here because it's the same root defect family and was fixed alongside) had the same + misreading-the-payload defect. + +**Pattern:** this is the clearest, most actionable pattern in the whole retrospective. "Is the head/PR I'm +about to act on still current" is asked at least 5 separate times across this file family, in 5 separate +hand-written implementations, and has failed in 5 separate ways — wrong field read, case sensitivity, +under-scoped match, missing check entirely, and the check itself lacking its own failure handling. Not one +of these was a repeat of a previously-fixed bug; each was a *new* mistake made writing a *new* copy of +conceptually the same check. + +### Shape 4: infrastructure/lifecycle issues, not code-logic bugs (3 incidents) + +12. **App token outlives a long review, publication fails with 401** (`.github` issue #1614, closed) — + Noema's long-running reviews (up to the documented 4-hour window) could outlive the GitHub App + installation token's lifetime, so a fully-computed, valid verdict failed to publish. Fixed by + refreshing/re-minting the token before publication rather than reusing the one minted at job start. +13. **`noema-review.yml`'s own concurrency group had no head-SHA component** (this session's item 13 + investigation, `docs/doctoring/item13-stale-head-cancellation-audit-20260903.md`) — GitHub's native + concurrency cancellation, not this file's own logic, could cancel a valid current-head run when an + older push's event was processed out of order. Fix proposed (`.github#1661`), not yet merged as of this + writing. +14. **`ORCHESTRATOR_PIN_SHA` staleness carrying forward a fixed upstream bug** — a pinned commit reference + needed bumping to pick up an unrelated fix (`stream_options`/`tools`) in the vendored gateway. + +### Shape 5: still-open, not yet resolved (3 incidents, tracked but unfixed) + +15. **`.github` issue #1611** (open) — the malformed-verdict retry path can lose track of the valid current + head and exhaust its retries via repeated `502`s from the gateway, a compound failure this + retrospective's Shape 1/Shape 3 fixes each partially address but that issue #1611 argues is not yet + fully closed as a combined scenario. +16. **`.github` issue #1613** (open) — already counted in Shape 1 (incident 4) as the still-open + budget-exhaustion variant. +17. **`.github` issue #1637** (open) — proposes a typed-blocker fail-closed path for invalid changed-line + citations / malformed JSON model output; overlaps with #1611/#1613 and Shape 1's incidents but has not + yet landed as a merged fix. + +## Cross-cutting pattern (all 17 incidents) + +Every incident in Shapes 1–3 (12 of 17) shares one structural cause: **`noema_review_gate.py` and its +sibling workflow YAML treat "guard against untrusted/racy input" as a per-call-site concern, discovered and +patched one call site at a time by external reviewers (Devin, CodeRabbit), rather than as a small number of +shared, centrally-tested primitives applied uniformly.** Three call sites independently parse/decode a +gateway response before a repair boundary (Shape 1). At least five call sites independently ask "is this +head/run still live" (Shape 3). Each new instance of "guard an I/O boundary" or "check liveness" is written +fresh, and each fresh instance has had its own, different bug — not because any one fix was careless, but +because there was no single, already-hardened helper to reuse. + +## Improvement plan + +**1. Extract one shared "decode and validate an untrusted LLM/gateway response" helper.** Currently +`extract_json_object`, the UTF-8 decode step, and the truncation-repair path (issue #1596) are three +separate functions with three separate guard histories. A single `parse_llm_response(raw_bytes) -> dict` +that owns byte-decoding, JSON parsing, and truncation detection — all inside one already-audited try/except +boundary — would mean a fourth "new response shape crashes before repair" incident has nowhere left to +hide; new failure *modes* would still need discovering, but the *boundary* itself would already be safe by +construction. **Not implemented in this record** — this is a refactor of live, security-critical CI logic +(same category this session has repeatedly deferred to its own dedicated PR rather than bundling into +documentation) and deserves its own PR with the exact regression tests each of the 4 Shape-1 incidents +already established, run against the unified helper. + +**2. Extract one shared "is this head/PR still the live one" primitive, and delete the 5 hand-written +copies.** Shape 3's 5 incidents are the strongest, most concrete case in this whole retrospective for a +single reusable function/action — e.g. a `scripts/ci/live_head_guard.py` with one well-tested +`assert_head_is_live(repo, pr_number, expected_head) -> bool` (or a composable Actions step) that every one +of `noema-review.yml`'s stale-trigger guard, `cancel-closed-pr-runs`, the repair-retry path, and +`opencode-review.yml`'s verdict poller calls instead of reimplementing. **Not implemented in this record** +for the same reason as (1) — this is the single highest-leverage follow-up this retrospective identifies, +and is recorded here explicitly so it is not lost, not treated as done. + +**3. Close the 3 still-open issues (#1611, #1613, #1637) as one coordinated fix, not three.** All three +describe overlapping symptoms of the same underlying gap (repair-retry robustness against a moving head +combined with a malformed/uncited verdict). Fixing them independently risks three more Shape-2-style +"the fix for one introduces a gap in another" incidents. Recommend one PR that addresses all three against +the unified helper from (1)/(2) once those land, rather than three separate patches. + +**4. Add a lightweight static check for the two recurring anti-patterns**, so a *sixth* Shape-1 or *sixth* +Shape-3 incident is caught before Devin/CodeRabbit finds it in review, not after: (a) any `response.read()`, +`.decode(...)`, or `json.loads(...)` on gateway/LLM output that is not textually inside a `try:` block +already known to feed the repair-retry path, (b) any `gh api` invocation in a bash step under +`set -euo pipefail` that is not wrapped in an `if ! ...; then` failure handler. A `semgrep` rule (this repo +already runs `sast-semgrep.yml` org-wide) or a small custom `scripts/ci/` lint check would fit the existing +CI surface. **Not implemented in this record** — scoping a new semgrep rule against this repo's actual +false-positive rate needs its own pass, separate from this retrospective's job of aggregating what already +happened. + +## What this resolves, and what it does not + +- **Resolves:** backlog item 23's "재취합" (re-aggregation) half in full — all 17 known incidents (14 + fixed, 3 open) are now indexed in one place with their shared root-cause shapes, rather than scattered + across 18 individual dated records with no cross-referencing. +- **Resolves:** the "개선안 도출" (produce an improvement plan) half at the level of *identifying* concrete, + scoped next steps (items 1–4 above) with enough detail for another agent or session to pick any one of + them up without re-deriving this analysis. +- **Does not resolve:** none of the 4 improvement-plan items are implemented here. Each is a code change to + live, security-critical CI logic (`noema_review_gate.py`, `noema-review.yml`, `opencode-review.yml`) that + deserves its own PR with dedicated regression tests, consistent with this session's practice of not + bundling a live-workflow-logic change into a documentation-only PR. The three still-open issues + (#1611/#1613/#1637) remain open. + +## Audit trail + +- `docs/product-technical-gap-baseline.md` — the 7 `noema-review-gate` incident sections this record + aggregates (all dated 2026-08-31, plus the item-13 concurrency finding dated 2026-09-03). +- `docs/doctoring/noema-model-output-repair-boundary.md`, `noema-orchestrator-free-zdr.md`, + `noema-repair-attempt-telemetry.md`, `noema-review-token-lifetime.md`, + `noema-token-lifetime-stale-run-retirement.md`, `autofix-and-noema-review-model-job-timeout-removal.md` — + the 6 pre-existing Noema-specific doctoring records this retrospective cross-references. +- `docs/doctoring/item13-stale-head-cancellation-audit-20260903.md` — the confirmed `noema-review.yml` + concurrency bug (Shape 4, incident 13), a distinct mechanism from the 17 incidents catalogued above. +- `ContextualWisdomLab/.github#1507` — the PR carrying 4 of the Shape 1/2 incidents (multiple Devin/CodeRabbit + review rounds on one PR). +- `ContextualWisdomLab/.github#1611`, `#1613`, `#1637` — the 3 still-open issues. +- `ContextualWisdomLab/.github#1596`, `#1614` — the 2 closed issues counted in Shapes 1 and 4. diff --git a/docs/doctoring/opencode-jsonc-nvidia-nim-block-removal.md b/docs/doctoring/opencode-jsonc-nvidia-nim-block-removal.md new file mode 100644 index 0000000000..db5aa5f964 --- /dev/null +++ b/docs/doctoring/opencode-jsonc-nvidia-nim-block-removal.md @@ -0,0 +1,106 @@ +# Doctoring record: removing the dormant `nvidia-nim` provider block from `opencode.jsonc` + +- **Date:** 2026-08-31 +- **Subject:** Two independent investigation passes traced every remaining candidate direct-NVIDIA-NIM + communication path in this repository, following up on `#1442`'s removal of the dead + `scripts/ci/select_nvidia_nim_model.py` resolver and `docs/product-technical-gap-baseline.md`'s + 2026-08-30 "ZDR/NIM-routing architecture review" entry (which investigated the same question and + chose to leave `opencode.jsonc`'s `nvidia-nim` block in place). This pass reaches a different, + narrower conclusion for that one block: it is fully dead for every automated/CI review path, was + never live for the reason previously assumed (a `NVIDIA_API_KEY`/`NVIDIA_NIM_API_KEY` naming + mismatch), and — more importantly — was pinned by two contract-test assertions in + `scripts/ci/test_strix_quick_gate.sh` that asserted its *presence* as if it were required, which is + itself misleading and worth fixing per this repo's contract-test discipline. +- **Related:** `#1442` (prior direct-NIM dead-code removal, same rigor: verify zero callers, doctoring + record, dated gap-baseline entry), `docs/adr/0003-contextual-orchestrator-vendored-free-zdr.md` + (the governing decision: gateway-only routing, fail-closed on gateway unavailability, no + direct-provider fallback), `docs/product-technical-gap-baseline.md`'s 2026-08-30 "ZDR/NIM-routing + architecture review" entry (superseded by this record for the `opencode.jsonc` block specifically; + left unedited per this repo's "append, don't rewrite history" convention — see the dated follow-up + entry added alongside this record). + +## What changed + +- Removed the `"nvidia-nim"` provider block from `opencode.jsonc` (previously lines 289-378: the + `baseURL`/`apiKey` options plus its ten-model catalog). `enabled_providers` (line 9) already listed + only `"contextual-orchestrator"`, so removing the block changes no runtime selection — it deletes + dead configuration, not live behavior. +- Fixed `scripts/ci/test_strix_quick_gate.sh`'s two orphaned assertions (previously lines 1481-1482, + missing the leading tab every neighboring assertion in the same function has — a sign they were + pasted in out of band) that asserted `opencode.jsonc` *contains* `"nvidia-nim"` and + `integrate.api.nvidia.com`. These were accurate when authored in commit `c61cb608` (`#1084`, + 2026-08-22, when `nvidia-nim` really was enabled), but `#1364` (`f8823a54`, 2026-08-27) flipped + `enabled_providers` to gateway-only and rewrote the surrounding workflow-file assertions to forbid + `nvidia-nim/*` without updating these two lines, leaving them pinning removed behavior as if it were + still required. Changed both to `assert_file_not_contains`, matching the two `assert_file_not_contains` + assertions immediately above them in the same function that already forbid the old NVIDIA NIM + model-id defaults. +- Deleted `docs/nvidia-nim-opencode-hotfix.md` per its own "Rollback" section ("drop the `nvidia-nim` + provider block ... and delete this note once GitHub Models / OpenCode catalog reliability is + restored"). Its `OPENCODE_MODEL_CANDIDATES` NIM-prefix rollback step and its + `NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}` workflow binding were already reverted by `#1364`; + this change completes the third and last rollback step the doc itself specified. Its only other + in-repo reference was the descriptive mention in `docs/product-technical-gap-baseline.md`'s + 2026-08-30 entry, which is left as-is per the append-only convention. + +## Why this is safe + +**Zero live callers, confirmed independently by two investigation passes:** + +1. `enabled_providers` (`opencode.jsonc:9`) already excluded `nvidia-nim` — OpenCode cannot select an + unenabled provider regardless of the removed block's content. +2. The dispatch and autofix workflows (`opencode-review-dispatch.yml`, `pr-review-autofix.yml`) build + their OpenCode config from scratch (`jq -n '{"provider": {}}'` plus a patched-in + `contextual-orchestrator` block only) — the root `opencode.jsonc`'s provider blocks were never + copied into the config either workflow actually runs OpenCode against. +3. `OPENCODE_MODEL_CANDIDATES` is set to the single literal value + `"contextual-orchestrator/orchestrator/free"` (`opencode-review-dispatch.yml`) — no `nvidia-nim/*` + candidates are ever dispatched. +4. The model-pool step's `env:` block does not forward `NVIDIA_NIM_API_KEY` at all (it is scoped only + to the earlier sidecar-provisioning step), so even the theoretical `{env:NVIDIA_API_KEY}` alias in + the removed block would have resolved empty in every workflow run today. + +Grepping the repository after this change for `nvidia-nim` and `NVIDIA_API_KEY` returns: +`scripts/ci/run_opencode_review_model_pool.sh` (dead candidate-handling branches, `is_nvidia_nim_candidate`/ +`is_schema_repair_candidate`/the credential bridge/`should_skip_model_candidate`/`cap_model_run_timeout` +— never exercised because no `nvidia-nim/*` candidate is ever dispatched per point 3 above; left +untouched in this change, split into its own follow-up per this org's stated preference for splitting +unrelated dead-code cleanups — see `#1437`'s review thread precedent), `scripts/ci/test_strix_quick_gate.sh` +(its own workflow-file assertions forbidding `nvidia-nim/`, unrelated `nvidia_nim`-with-underscore +fixture values inside Strix's own quick-gate self-test harness, and the two now-corrected assertions +above), and `.github/workflows/hourly-nvidia-nim-review-repair.yml` plus its per-product hourly-caller +tests (named after the scheduler's NIM heritage but gateway-only per ADR-0003/CLAUDE.md — unrelated to +`opencode.jsonc`'s provider block). No executable reference to the removed block remains. + +**A second, separate audit traced the other candidate direct-NIM surfaces flagged for this pass and +found no live communication to remove:** + +- `scripts/ci/strix_quick_gate.sh`'s `is_known_foreign_provider_api_base()` (single caller inside + `resolved_llm_api_base_for_model()`) is a leak-*blocker* — matching it clears a resolved API base + rather than granting one, specifically to stop a leaked NVIDIA NIM/GitHub Models/OpenRouter base URL + from being reused when Strix falls back to an explicit direct-OpenAI model. It is also unreachable + in the wired `strix.yml` today, since that workflow hardcodes `STRIX_LLM_FILE` to the literal + `orchestrator/free` and forces `STRIX_FALLBACK_MODELS: ""`. Left untouched: it is a correctness + guard with its own dedicated regression test + (`tests/test_strix_openai_fallback_api_base.py`), not a bypass. +- All four workflows that provision `NVIDIA_NIM_API_KEY` (`noema-review.yml`, `opencode-review-dispatch.yml`, + `pr-review-autofix.yml`, `strix.yml`) do so only as an `env:` input to the + "Provision contextual-orchestrator ... sidecar" step, which registers the secret into the vendored + gateway process's own KV (`register_review_credentials`) — never into a direct `curl`. None of the + four workflow files reference `integrate.api.nvidia.com`. +- `scripts/ci/zdr_policy.py`'s `PROVIDER_BASE_URLS["nvidia_nim"]` fallback is consumed only inside the + vendored `contextual-orchestrator` sidecar process itself (`contextual_orchestrator_review_launcher.py`, + `contextual_orchestrator_review_policy.py`), building the gateway's own internal routing table for + models it discovered via the KV credential it registered. This is the intended architecture — "the + writer runs `contextual-orchestrator/orchestrator/free`" per `AGENTS.md` — not a bypass of it. +- The 2026-08-30 ADR-0003 amendment already confirms `strix.yml` forces `orchestrator/free` with zero + fallback candidates and fails closed unless the sidecar reports the exact expected loopback base URL; + no remaining Strix code path can select `nvidia_nim/*` directly. Left untouched. + +## Audit trail + +- `#1442`'s doctoring record and `docs/product-technical-gap-baseline.md`'s 2026-08-30 entry — the + prior investigation this pass follows up on and narrows. +- This PR's own two investigation passes (`opencode-config`, `strix-noema-allowlist`) — full + file/line traces underlying the summary above. +- This PR's diff — the removal and contract-test fix themselves. diff --git a/docs/doctoring/org-queue-sweep-rotation.md b/docs/doctoring/org-queue-sweep-rotation.md index 1c6206419b..03784d0b7e 100644 --- a/docs/doctoring/org-queue-sweep-rotation.md +++ b/docs/doctoring/org-queue-sweep-rotation.md @@ -104,6 +104,36 @@ organization Billing/Budgets visibility can tune either limit independently. itself would make a stacked PR appear default-base and bypass its central OpenCode dispatch path. +## Shared-installation rate-limit boundary + +The scheduler and several sibling workflows use installation access tokens +from one GitHub App installation. GitHub applies one primary request bucket to +that installation: at least 5,000 requests per hour, scaling by organization +users and repositories to at most 12,500 requests per hour outside GitHub +Enterprise Cloud. In a 30-run scheduler sample, 5 runs failed with the same +primary-limit diagnostic across more than 15 hours; 4 failed on the first of +66 repositories within 5 to 18 seconds. That aggregate timing evidence is +consistent with shared-bucket contention rather than one target repository +consuming the budget. + +REST and GraphQL reads therefore make at most four attempts. Primary-limit +failures use the reset epoch reported by `GET /rate_limit`, capped at 60 +seconds for each retry interval; other transient failures retain the shorter +exponential backoff. GitHub documents that the rate-limit endpoint does not +consume the primary REST budget, although it can consume secondary capacity, +and recommends waiting until the reported reset rather than continuing to +send requests after a primary limit is exhausted. + +If bounded retries still end with `API rate limit exceeded`, the workflow +records the current repository as deferred and stops the organization loop. +The bucket is shared, so visiting the remaining repositories cannot produce +new authoritative state before reset; it would only repeat up to three +one-minute waits per repository and add queue-hygiene requests that GitHub +explicitly advises against. The capacity condition remains non-fatal and the +rotating next execution retries unfinished work. Secondary-limit diagnostics +remain outside this narrow classifier because GitHub gives them a different +retry contract and may provide `Retry-After` instead of a primary reset epoch. + ## Verification - `tests/test_required_workflow_queue_contract.py::test_org_queue_sweep_rotation_offset_is_deterministic_and_reorders_targets` @@ -125,8 +155,13 @@ organization Billing/Budgets visibility can tune either limit independently. test-injection and fail-closed-validation paths. - `test_org_queue_sweep_documents_rotation_leverage_and_validates_input` locks the `#1219` cross-reference, confirms `github.run_number` is not - reintroduced as the source, and confirms the ordinary budget remains - independently configurable from the stacked budget. + reintroduced as the source, confirms the shared budget constant itself + is untouched, and confirms the ordinary budget remains independently + configurable from the stacked budget. +- `test_org_queue_sweep_treats_rate_limited_repositories_as_non_fatal` + confirms the primary-limit diagnostic is deferred without becoming a generic + hard failure and that the repository loop stops immediately after recording + the exhausted shared bucket. - `actionlint` (with `shellcheck` on `PATH`) reports no findings against the modified workflow. @@ -139,3 +174,15 @@ per-execution-guarantee review discussion. `ContextualWisdomLab/.github#1223` — wall-clock correction, then the persistent-counter correction this document and the current workflow source reflect. + +GitHub, Inc. (n.d.-a). *Best practices for creating a GitHub App*. GitHub +Docs. Retrieved August 24, 2026, from +https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/best-practices-for-creating-a-github-app + +GitHub, Inc. (n.d.-b). *Rate limits for GitHub Apps*. GitHub Docs. Retrieved +August 24, 2026, from +https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/rate-limits-for-github-apps + +GitHub, Inc. (n.d.-c). *Rate limits for the REST API*. GitHub Docs. Retrieved +August 24, 2026, from +https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api diff --git a/docs/doctoring/org-required-workflow-rollout-history-preservation.md b/docs/doctoring/org-required-workflow-rollout-history-preservation.md new file mode 100644 index 0000000000..79c9d3b713 --- /dev/null +++ b/docs/doctoring/org-required-workflow-rollout-history-preservation.md @@ -0,0 +1,65 @@ +# Organization required-workflow rollout history preservation + +Status: Proposed evidence ledger +Date: 2026-09-02 KST +Canonical owner: `ContextualWisdomLab/.github` +Source snapshot preserved: `80fdc4388ea6bc94eab69c410cb957e52f5cd4f5:docs/org-required-workflow-rollout.md` + +## Purpose + +The current rollout document was reconciled from the historical seven-workflow incident state to the live ten-workflow contract. That reconciliation must not erase valid operational evidence merely because the current policy changed. This doctoring record preserves the superseded-but-valid incident chronology that operators and later agents may need to reconstruct why the control plane looks the way it does. + +The current authority is the live ruleset plus the exact-inventory audit and its independent regression oracle. Items below are historical evidence, not permission to restore superseded behavior. + +## Preserved control-plane chronology + +- On 2026-06-28 20:09 KST, organization ruleset `18156473` was re-pinned to `.github@main` SHA `531482764986bf7da98c1317d59e6e51e7c61d02` for the then-current three required workflow paths. +- `ContextualWisdomLab/naruon` reported inherited active ruleset `18156473` with those three required workflow paths, establishing early target-repository inheritance. +- `ContextualWisdomLab/ContextualWisdomLab.github.io#25` merged the thin central scheduler caller and repository-local bootstrap fixes; its main Strix run `28217860369` passed. +- `ContextualWisdomLab/.github#74` changed OpenCode review model order to DeepSeek R1 first and added a catalog fallback pool. +- `ContextualWisdomLab/.github#75` removed the Strix finding against the scheduler command wrapper by using `subprocess.run(..., check=True)` while preserving the scrubbed failure contract. Main Strix run `28218982899` passed after merge. +- `ContextualWisdomLab/.github#77` merged the central OpenCode required-workflow path. Same-head OpenCode proof run `28224085121` passed coverage evidence, CodeGraph initialization, bounded evidence preparation, model review, review publication, and approval-gate publication on head `59a8da0b2f56b862f6c5a0c69885f4045d6dc732`; central Strix run `28223698075` passed on that same head. +- Ruleset `18156473` was then renamed `CWL Central required workflows` and required `.github/workflows/strix.yml` and `.github/workflows/opencode-review.yml` from `.github@main` SHA `6440d493816f8a4d66e32f2e5e8e6a9156d7f488`. +- `ContextualWisdomLab/.github#79` merged the central scheduler `pull_request_target` path and PR-scoped `--pr-number` lookup. Its second current-head proof passed coverage evidence in 10 seconds, Strix in 8m33s, and OpenCode review in 8m57s on head `17c62f3809c57ca4b1a9a63e14f325c9f2a1acdb`. +- Ruleset `18156473` subsequently required Strix, OpenCode, and the PR Review Merge Scheduler from `.github@main` SHA `807254a04efafd5f806e0f70cb067ecf050cfd11`. +- `ContextualWisdomLab/.github#85` installed target-repository `requirements.txt` before Python coverage evidence; `#88` hardened the OpenCode output normalizer; `#94` hardened Mermaid labels; `#95` blocked approvals contradicting exact changed-file evidence. +- `ContextualWisdomLab/.github#100` added required-workflow job rerun support and cancellation of older same-PR OpenCode runs before retrying current head. Local verification on `3c62c37a4deabdb0c6ed4ddf0951c1987f09866b` reported 38 pytest tests, 100% coverage, and 100% interrogate. It merged at `81408f3dbe0a3c43dc4b76133f72a5e314df8a10` on 2026-06-29 05:45 KST. +- `ContextualWisdomLab/.github#136` changed approved stale PR handling so `BEHIND` branches are updated before failed-check or `ACTION_REQUIRED` decisions disable auto-merge. +- `ContextualWisdomLab/.github#137` made the central PR Review Fix Scheduler target-repository-aware across workflow call, dispatch, schedule, and repository variables; the later central autofix worker made `.github` the default autofix owner rather than copying full workers into consumers. +- `ContextualWisdomLab/.github#138` added compare-API branch-freshness evidence; `#140` extended update-branch handling to already-auto-merge-enabled PRs; `#145` treated compare `status: behind` as freshness evidence and merged at `1ec0f3dcc7250fdf4a5a3ec6c26feaa98cce4f48`. +- A 2026-06-30 00:40 KST dry run found update-branch candidates in `ContextualWisdomLab/.github#147` and `ContextualWisdomLab/naruon#803`. `ContextualWisdomLab/.github#151` added protected-base push triggers and the `auto_merge_enabled` event, merged as `00018f7783522447a71acd08a946e3504e18ff74`, and created push-triggered scheduler run `28385177585`; that run remained queued awaiting runner assignment. +- `ContextualWisdomLab/.github#146` taught central OpenCode coverage evidence to discover nested requirements-only Python projects and merged at `0393bc1c48b80597d6d35c336aca43aee18e22b9`. +- `ContextualWisdomLab/.github#149` tightened the central model-failure path and merged at `919b83faf29237803cfdd0cfd6febbe5ae1a8a3c`. Follow-up `6fdffe43b50a2246b3db2790a0ab532618a89c2b` fixed temporary evidence-file handling. Local validation covered pytest, 100% coverage, 100% interrogate, actionlint, bash syntax, and diff checks; the full quick-gate exceeded the local 300-second environment cap and was not represented as complete evidence. +- `ContextualWisdomLab/semantic-data-portal#3` removed repository-local OpenCode, Strix, and scheduler workflows. `ContextualWisdomLab/pg-erd-cloud#361` removed its repository-local PR Review Fix Scheduler wrapper after central ownership matured and merged at `21cbc14b21d59ac28ac789de58502816cc8df6ad`. +- `ContextualWisdomLab/naruon` classic protection later stopped requiring direct `strix` or `opencode-review` contexts on `develop` while org ruleset `18156473` remained authoritative. `ContextualWisdomLab/naruon#852` moved release-governance contracts to the central scheduler model; its first central coverage run exposed the nested-requirements defect later repaired by `.github#146`. + +## Preserved review/merge evolution + +- `ContextualWisdomLab/.github#225` raised high reasoning effort for reasoning-capable OpenCode definitions and merged at `50c6ef82f52af3eeb0e58c174902fc9855c36682`. +- `#226` stopped previous deterministic fallback approval bodies from satisfying current-head evidence and merged at `57a1fa580731a0f76b31dcf29a597c5715dba2fd`. +- `#230` added exact changed-file candidates to merge-conflict guidance and merged at `0cab5c8d46e88c1a3f68ef3f71b5d44d971cd2ef`. +- `#232` removed the workflow-only deterministic approval fallback and merged at `f545a9917933f8f81a76ea0044cbce0aae1ac5bd`. +- `#233` blocked false trivial approval reasons for material workflow/source/test changes and merged at `4ff660c8396b78a1b82aef8c316b26527864d450`. +- `#234` repaired changed-file evidence parsing and merged at `da3a4a5788e7019229d66247c360b258b1a5b1f7`. +- `#235` preferred the workflow token for same-repository post-approval merge/update and merged at `482b05c6c11d9da9895246406aca1c3bd8f6a691`. +- `#239` centralized the reasoning-effort guard and merged at `2aa1fa36255a558bafca05567125ef7e44571976` after current-head coverage, Strix, OpenCode, Noema, and scheduler evidence passed. +- `#242` added REST fallbacks for transient scheduler GraphQL reads and merged at `0d2c6d9e7ae1bad947e7ee3629e2a412ac2ce248`. +- `#244` added the central PR Review Autofix worker and merged at `4d2dd64028231b1154642bfe23b822fc3403e217`. +- `#246` hardened model-pool exhaustion handling and merged at `f5f00b782ae4f7806f0e3197bf9b49c9c5a2cb91`. +- Historical `#247` was not merged because it would have accepted previous-parent approval evidence after model exhaustion; its rejection is preserved as an explicit fail-closed precedent rather than a reusable approval path. +- `#249` constrained autofix dispatch to source-actionable current-head review findings and merged at `dbd33b3a0384de0129aa082a210383188d012415` after current-head evidence passed. +- `#255` removed the remaining deterministic low-risk approval fallback and merged at `e2beae72b87a8817cd57f9f51bab3947353baa61`; an initial review-publication rate limit was followed by a successful rerun and native auto-merge. +- `#283` refreshed reasoning-capable OpenCode configuration and merged at `ef9950e6b55bf943c0295e1df3e34c94210d21cc`. + +## Preserved downstream incidents + +- After `.github#255`, `ContextualWisdomLab/bandscope#493`, `#494`, `#495`, and `#500` were rechecked. Merge simulation found genuine conflicts, including `apps/desktop/src/App.tsx` and design-system documentation; those were conflict-repair findings, not update-branch candidates. +- `ContextualWisdomLab/aFIPC#78` eventually merged after current-head central `coverage-evidence`, `opencode-review`, `strix`, and `scan-pr-queue` passed on `b1ddafced86302f461e95259699f1efde5ec87c9` and OpenCode approved the same head. +- `ContextualWisdomLab/pg-erd-cloud#393` removed the repository-local autofix worker. Its first OpenCode run on `9d8eed5be47670b1b46f413295d9a6044d7327b2` exhausted the older pool; after `.github#246`, run `28485070313` approved the same head and the PR merged at `1e0d6a3dda5ea9afcd74dcd8380689672e1c8ef1`. +- A 2026-07-02 18:15 KST non-fork inventory found 17 public non-fork repositories, inherited ruleset `18156473` on `kaefa` and `waf-ids-ai-soc`, and no default-branch copies of the central OpenCode/Strix/scheduler workflows outside `.github`. +- `ContextualWisdomLab/waf-ids-ai-soc#6` merged at `e1c0a85fd4a8e6dd67039be43eb7f659fec22abd` after central required-workflow proof on head `43b62b5f347d1532c81b5ae38d8e41b4494fd486`; historical `#8@48d8b56a0f995829fc95de4fed129d1c33aaadff` was the next runtime-proof fixture. +- Historical `ContextualWisdomLab/kaefa#60@13c9089855fcdd34391173560ccf6935bac1eebe` exposed missing central-check materialization even though the repository inherited the ruleset; current PR state must always be re-read instead of inheriting that old status. + +## Preservation invariant + +The live ten-workflow contract supersedes the old seven-workflow operator state, but not the evidence explaining how it evolved. Future edits to the rollout summary may compact historical prose only when the semantic facts remain reconstructible from this record or another immutable evidence document. Current-head Checks, reviews, ruleset reads, and exact repository state always outrank this historical ledger for admission decisions. diff --git a/docs/doctoring/organization-commercial-readiness-loop.md b/docs/doctoring/organization-commercial-readiness-loop.md index bde6539aba..60675f6df6 100644 --- a/docs/doctoring/organization-commercial-readiness-loop.md +++ b/docs/doctoring/organization-commercial-readiness-loop.md @@ -1,5 +1,15 @@ # Organization commercial-readiness coordinator +## 2026-09-04 quality-job consolidation + +The commercial-readiness contract suite now runs conditionally inside +`.github/workflows/agent-review-runtime-quality-ci.yml`. The standalone thin +caller was removed, while the shared +`.github/workflows/exact-head-coverage-quality-gate.yml` implementation remains +available to its other caller. Matching pull requests reuse the agent-quality +job's exact-head checkout, Python setup, and hash-verified base dependencies; +the 100% branch-coverage and compile contracts are unchanged. + ## Decision ContextualWisdomLab uses one organization-central hourly coordinator for repositories that do not already have an enabled dedicated commercial, maintenance, review-repair, or product-development writer. The coordinator complements rather than duplicates the existing 15-minute organization merge scheduler. diff --git a/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md new file mode 100644 index 0000000000..b81778cfef --- /dev/null +++ b/docs/doctoring/pr-review-merge-scheduler-trigger-audit-20260903.md @@ -0,0 +1,116 @@ +# Doctoring record: pr-review-merge-scheduler.yml's "fires at every step" pattern is by-design, not a bug (2026-09-03) + +- **Date:** 2026-09-03 +- **Subject:** the user directly observed the scheduler workflow firing repeatedly ("왜 각 모든 단계마다 Trigger + 되고 있죠?") after live evidence surfaced today of severe org-wide Actions thrashing (near-zero completion + rate; a peer's independent measurement found ~3 jobs in_progress against ~9,368 queued org-wide, and this + session independently confirmed 10 in_progress / 1,713 queued / zero successes in the last 20 runs for + `.github` alone). Directed to trace and fix the workflow issues causing it, with bypass-merge explicitly + authorized for this chicken-and-egg case. +- **Decision record:** none in `docs/adr/` — negative/confirmatory finding for this specific file, cross- + referenced against a real, separate fix a peer session applied to a different file in the same + investigation. +- **PR:** `ContextualWisdomLab/.github#1763`. + +## Method + +Fetched `pr-review-merge-scheduler.yml` fresh from `raw.githubusercontent.com` at commit `8c08583` +(the file's own last-modifying commit on `main` as of this writing; re-verify against a fresh +`gh api "repos/ContextualWisdomLab/.github/commits?path=.github/workflows/pr-review-merge-scheduler.yml&sha=main"` +call if the file has changed since) and read its full trigger +surface, concurrency configuration, and `scan-pr-queue` job's `if:` guard. Cross-referenced against a peer +session's concrete evidence (PR `ContextualWisdomLab/naruon#1741`: 90 total workflow runs on that PR's branch, 10 of them +"Required PR Review Merge Scheduler"). Traced the `rerun-failed-jobs` mechanism referenced in this file's +`workflow_run` listener back to its source in `opencode-review-dispatch.yml` to determine whether it is a +chronic, repeated re-trigger source or a bounded, once-per-cycle event. + +## Result: the trigger surface is legitimately event-reactive, not redundant + +`pr-review-merge-scheduler.yml`'s `on:` block listens for: `push` (protected branches), `pull_request_target` +(6 types), `pull_request_review` (2 types), `workflow_run` on exactly two named workflows ("Required +OpenCode Review", "Strix Security Scan") with `types: [completed]`, two `schedule` crons (offset by 30 +minutes to avoid collision, each independently justified in the file's own comments for a specific coverage +gap), `workflow_call`, and `repository_dispatch`. Every one of these represents a genuinely distinct, +actionable state change the scheduler exists to react to: + +- A push (new commit) changes what the scheduler should evaluate. +- A review submission/dismissal changes approval state. +- "Required OpenCode Review" completing is new information the scheduler needs to decide on branch + updates/auto-merge — the scheduler cannot know a review landed without being told. +- "Strix Security Scan" completing is the same, for the security gate. +- The two schedule crons close real, already-documented coverage gaps (this repository's own PR queue has + no other periodic fallback since `org-queue-sweep` explicitly excludes `ContextualWisdomLab/.github`; a + PR whose last required check to go green has no dedicated `workflow_run` listener otherwise stalls with + no re-wake at all). + +The `rerun-failed-jobs` call inside `opencode-review-dispatch.yml`'s "Wake exact-head required OpenCode +workflow" step (which would itself re-trigger the scheduler via `workflow_run` on completion) is gated +behind `steps.formal_review_receipt.outcome == 'success'` and only fires when the required run is +`completed`+`failure` — a bounded, once-per-review-cycle continuation of an already-published receipt, not +a chronic re-fire loop. + +**PR `ContextualWisdomLab/naruon#1741`'s 10 scheduler runs are consistent with this legitimate surface** (push(es) + review +submission(s) + OpenCode completing + Strix completing + the two hourly/30-minute heartbeats over the PR's +open lifetime), not evidence of a bug in this file's trigger design. + +## The actual mechanism behind the observed thrashing is elsewhere, and already being fixed + +`cancel-in-progress` in this file is `true` only for `pull_request_target`, `pull_request_review`, +`repository_dispatch`, and the no-PR-number `workflow_run` branch — every one of which represents a +genuinely new triggering event that supersedes the scheduler's prior, now-stale, in-flight evaluation, for +branch-specific reasons: a new `pull_request_target` event means a push or review-state change already +invalidated whatever the prior run was computing; a new `pull_request_review` means an approval/change-request +just arrived; a new `repository_dispatch` is an explicit, deliberate re-invocation (a manual retry or a +cross-repo caller); and the no-PR-number `workflow_run` branch fires only for events with no associated PR +(so there is nothing PR-specific yet to preserve). `workflow_run` itself — CodeRabbit correctly noted — is a +workflow-completion event, not a direct user action; grouping it under "user-driven" was imprecise. It is +explicitly `false` for the +PR-associated `workflow_run` branch (OpenCode/Strix completing), so those queue rather than evict an +in-progress run. This matches the same correctly-scoped pattern already confirmed for `strix.yml`, +`opencode-review.yml`, and `noema-review.yml` in `docs/doctoring/item13-stale-head-cancellation-audit-20260903.md` +(a separate, not-yet-merged PR as of this writing — see `ContextualWisdomLab/.github#1760`; that doc will +not exist on this branch until it merges) — **no self-defeating cancellation bug was found in this file.** + +A peer session, working the same live-evidence investigation, found and fixed a real bug in a related +file, in two rounds (`ContextualWisdomLab/.github#1661`): the former standalone `current-head-run-coalescer.yml` (the mechanism now integrated into the merge scheduler) +specifically meant to prune stale-SHA queued runs) carried `cancel-in-progress: true` on its own PR-scoped +concurrency group — but under today's unusually high push volume from four concurrent agent sessions, each +new push cancelled the coalescer's own prior in-flight attempt before it could get a runner, so it never +actually executed for a busy PR. The first fix (commit `c0dc46b`, flipping `cancel-in-progress` to `false`) +was itself caught as incomplete by Devin Review: a plain `cancel-in-progress: false` only protects a +*running* job — GitHub concurrency groups still silently evict a *pending* (queued) run the instant another +run enters the same group, regardless of `cancel-in-progress`, which is exactly the failure mode that had +been observed (a required-review check sat stuck queued with the coalescer never once executing for it). +The complete fix (commit `12d5735`) adds `queue: max`, a GitHub Actions concurrency feature — an +already-precedented pattern in this repo (`agent-mention-router.yml`) — that retains up to 100 pending runs +instead of evicting all but the latest. **Precision on `queue: max`'s own limits (CodeRabbit correctly +caught the original wording overclaiming this):** the 100-pending-run retention is a hard cap, not +unlimited — a burst exceeding it can still evict overflow arrivals; and GitHub does not guarantee strict +FIFO dispatch order for the retained runs (ordering is based on when each run started waiting on the group, +not when it was originally triggered, and that too is not a hard guarantee). Neither limit changes the +verdict for the specific incident this fix responds to (PR `#1741`'s push volume was far below the 100-run +cap), but "runs them in order" should not be read as a general ordering guarantee beyond that — see +the residual-gap note in `docs/doctoring/current-head-run-coalescing.md` for the fuller caveat. Combined +with the coalescer script's own live-state re-fetch (confirmed safe for a surviving queued instance to run +later, since it never trusts the head SHA it was triggered with), that was a genuine, two-round +self-starvation bug, distinct from anything in this file, and is the more direct, evidence-backed +explanation for the observed churn than this workflow's trigger breadth. + +**Conclusion:** forcing a change to this file's trigger surface (removing `workflow_run` listeners, say) on +the strength of the "fires at every step" observation would have traded real event-reactivity (the +scheduler promptly noticing a review or a security verdict landing) for a fix that does not address the +actual mechanism — consistent with this session's practice of not forcing a change that a real look shows +is not the right lever. Real, safe progress was made instead: PR `#1725` (the `dependency-review.yml` +fail-closed hardening this session's separate consolidation effort is blocked on) was found `mergeable_state: +behind` with most required checks already green and only a handful still queued; its branch was updated +(a normal, non-bypass maintenance action) to let its remaining checks proceed once runner capacity allows. + +## Audit trail + +- `docs/doctoring/item13-stale-head-cancellation-audit-20260903.md` — the sibling investigation this record + extends, confirming the same "correctly scoped, not a bug" pattern for the other three central workflows. +- `docs/doctoring/actions-plan-concurrency-ceiling-20260903.md` — the underlying capacity finding this + thrashing evidence corroborates rather than replaces. +- `ContextualWisdomLab/naruon#1741` — the concrete 10-run/90-total-run example cross-checked here. +- `ContextualWisdomLab/.github#1725` — the dependency-review consolidation prerequisite whose branch was + updated as part of this investigation's concrete follow-through. diff --git a/docs/doctoring/queue-hygiene-live-ref-race.md b/docs/doctoring/queue-hygiene-live-ref-race.md index 029cdf31f3..2fe172fe65 100644 --- a/docs/doctoring/queue-hygiene-live-ref-race.md +++ b/docs/doctoring/queue-hygiene-live-ref-race.md @@ -1,5 +1,9 @@ # Queue-hygiene live-ref race doctoring +> Superseded 2026-09-04. The cross-repository queue-cancellation owner and its +> helper were removed; native per-PR concurrency and the local exact-head +> coalescer now own supersession. The material below is retained as incident history. + ## Incident The organization queue sweep classified queued/in-progress Actions runs against a pull-request list snapshot and later cancelled the selected run IDs. A PR head can advance after that snapshot but before the destructive cancellation. GitHub's run and PR payloads may also lag the branch ref. Trusting either predecessor snapshot as final authority can therefore cancel the sole current-head review/check evidence and amplify Actions-capacity saturation. diff --git a/docs/doctoring/r-cmd-check-reusable-workflow-consolidation.md b/docs/doctoring/r-cmd-check-reusable-workflow-consolidation.md new file mode 100644 index 0000000000..614c5b4ab7 --- /dev/null +++ b/docs/doctoring/r-cmd-check-reusable-workflow-consolidation.md @@ -0,0 +1,55 @@ +# R-CMD-check reusable workflow consolidation + +## Current authority + +This record describes the Proposed owner change in `ContextualWisdomLab/.github#1716`. Protected `main` remains production authority until the exact candidate integrates. Consumer PRs in `ContextualWisdomLab/kaefa` and `ContextualWisdomLab/nonnest2` must not consume this PR branch or mutable `@main`; after integration they pin the exact protected-main commit that contains the reusable workflow. + +## Original duplication + +`ContextualWisdomLab/kaefa` and `ContextualWisdomLab/nonnest2` both derived their R-CMD-check workflow from the r-lib Actions examples. Their common sequence and common authority fields justified a canonical reusable owner. Their real differences are bounded data/capabilities: trigger branches, R matrix, TinyTeX requirement, extra R packages, check arguments, and kaefa's one testthat regression. + +The action pins selected by the proposal are `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1` and `r-lib/actions/*@6f6e5bc62fba3a704f74e7ad7ef7676c5c6a2590`. `permissions: contents: read`, `GITHUB_PAT`, `R_KEEP_PKG_SOURCE`, `build_args`, `error-on`, and snapshot-upload behavior remain owned centrally rather than becoming consumer inputs. + +## Security RCA: free-form pre-check shell + +The first candidate represented kaefa's two-command regression as a string input named `pre_check_script` and executed it with `run: ${{ inputs.pre_check_script }}`. Devin current-head review identified the resulting security boundary defect: reusable-workflow callers could provide arbitrary Bash source to a central job that receives the caller repository token. + +This is a canonical-owner defect, not a finding to suppress or merely document. The repair lineage on 2026-09-02 is: + +- RED commit `5e838ab35d062faa488b03ae78f9f8d84447e223`: adds an executable contract forbidding `pre_check_script`/caller-authored `run:` and requiring a bounded test-file data path; +- production commit `931c8f32a2e5e743ca0fbdee3d6728170ff2b273`: removes arbitrary shell input and introduces `install_package_before_pre_check` plus `pre_check_test_file`; +- contract-alignment commit `6ca3080326f3498904d6222c60089e35a050b848`: verifies step order, capability gates, environment-data binding, and fail-closed path checks on the repaired source. + +The repaired workflow owns its executable commands. When requested, it runs a fixed package installation command. The optional test file is passed only as `PRE_CHECK_TEST_FILE`, must match repository-relative `tests/testthat/*.R`, and is rejected for parent traversal, absolute-path prefixes, carriage returns, or newlines before the fixed `testthat::test_file(Sys.getenv("PRE_CHECK_TEST_FILE"))` command executes. No consumer string is evaluated as shell source. + +## Consumer equivalence + +The bounded replacement preserves kaefa's valid behavior without preserving the unsafe representation. Its former commands were: + +1. install the current package from source; +2. run `tests/testthat/test-zh-misfit-decision-rule.R` through testthat. + +The equivalent bounded caller values are: + +- `install_package_before_pre_check: true`; +- `pre_check_test_file: tests/testthat/test-zh-misfit-decision-rule.R`. + +Kaefa's five-leg R matrix, `any::rcmdcheck` + `any::testthat`, and `c("--no-manual", "--no-tests")` remain data inputs. Nonnest2 needs no pre-check capability and keeps its own trigger branches/TinyTeX behavior. Each consumer must pin the eventual owner protected-main SHA and regenerate its own current-head evidence. + +## Validation contract + +`tests/test_r_package_check_reusable_workflow_contract.py` checks the six bounded inputs, optional-step gates, immutable action pins, uniform central fields, matrix binding, absence of free-form shell input, and the fail-closed test-file grammar. Repository-wide pytest/coverage, docstring checks, actionlint, security workflows, and current-head independent review remain merge evidence only when they execute on the unchanged exact current head; predecessor results are historical evidence, not transferable approval. + +The unresolved Devin thread on the vulnerable implementation must remain unresolved until exact-head evidence proves the repaired successor. Queue saturation is not authority to bypass this substantive security finding. + +## Context and standards + +Reusable workflows establish an execution boundary: GitHub explicitly documents that called workflows receive permissions constrained by the caller and that permissions cannot be elevated through the call chain. This repair additionally minimizes the command surface so caller-controlled values remain data rather than command text. Shell/path validation here is defense in depth; the primary design rule is that the workflow itself owns executable source. + +## References (APA 7th edition) + +GitHub, Inc. (n.d.). *Reusing workflows*. GitHub Docs. Retrieved September 2, 2026, from https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows + +GitHub, Inc. (n.d.). *Workflow syntax for GitHub Actions*. GitHub Docs. Retrieved September 2, 2026, from https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax + +r-lib. (n.d.). *actions: GitHub Actions for the R community* [Computer software]. GitHub. Retrieved September 2, 2026, from https://github.com/r-lib/actions/tree/v2/examples diff --git a/docs/doctoring/required-workflow-path-filter-boundary.md b/docs/doctoring/required-workflow-path-filter-boundary.md new file mode 100644 index 0000000000..65abac141a --- /dev/null +++ b/docs/doctoring/required-workflow-path-filter-boundary.md @@ -0,0 +1,221 @@ +# Required-workflow path filters: trigger level is a no-go, job level is safe + +**Status:** active repair evidence +**Owning repository:** `ContextualWisdomLab/.github` +**Canonical repair PR:** see `docs/org-required-workflow-rollout.md` entry below +**Protected baseline:** `main@bf5970df983dd36e3372c124778ec60857414eba` + +## The question + +Runner-admission pressure (queue-congestion investigation: 9,368 checks +queued organization-wide, roughly 3 in progress, queue depth roughly equal to +open-PR-count times required-workflow-count) makes it tempting to add +`paths:`/`paths-ignore:` to the `on:` trigger of a required workflow so a +docs-only PR never admits an expensive job (Strix, Semgrep, CodeQL, Trivy, +OSV, Scorecard). Whether that is safe depends on how the check actually gets +created in a target repository. + +## Live re-verification (this phase, not taken on faith) + +Organization ruleset `18156473` ("CWL Central required workflows"), fetched +live via `gh api orgs/ContextualWisdomLab/rulesets/18156473`: + +```json +{ + "conditions": { + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + "repository_name": {"include": ["~ALL"], "exclude": ["noema", ".github", "IRT-bibliography-set"]} + }, + "rules": [ + ".github/workflows/opencode-review.yml", ".github/workflows/pr-review-merge-scheduler.yml", + ".github/workflows/security-scan.yml", + ".github/workflows/strix.yml", ".github/workflows/sast-semgrep.yml", + ".github/workflows/noema-review.yml", ".github/workflows/codeql-pr.yml", + ".github/workflows/scorecard-pr.yml", ".github/workflows/osv-scanner-pr.yml" + ] +} +``` + +This historical snapshot predates the empty-PR cleanup consolidation. The +current ruleset has six workflows; `pr-review-merge-scheduler.yml` owns that +metadata-only decision. GitHub's required-workflow ruleset executes +each listed workflow **file from this repository** inside every covered +target repository's context, evaluated against that target repository's own +events. Confirmed live that the target repository's own `on:` filters (paths, +paths-ignore, branches, types) play no part in that: `bandscope`'s own +workflow directory is + +``` +bandit.yml build-baseline.yml ci.yml codeql.yml ossf-scorecard.yml +release.yml sbom.yml secret-scan-gate.yml security-audit.yml trivy.yml +``` + +— it has **no local** `codeql-pr.yml`, `strix.yml`, or `security-scan.yml` — +yet ruleset-injected runs of all three routinely execute against its PRs. A +`paths-ignore:` written into this repository's copy of those files is +therefore **inert** in `bandscope` and the 40+ other ruleset-covered repos: it +is never evaluated, because the check that fires belongs to the injected run, +not a repository-local trigger. + +`ContextualWisdomLab/.github`'s own `main` branch is excluded from ruleset +`18156473` (see `repository_name.exclude` above) and instead uses **classic** +branch protection, fetched live via +`gh api repos/ContextualWisdomLab/.github/branches/main/protection`: + +``` +strict: true enforce_admins: false +contexts: + Detect CodeQL languages + CodeQL compatibility analysis (actions) + CodeQL compatibility analysis (python) + scan-pr-queue + dependency-review + osv-scan + osv-scan / osv-scan + trivy-fs + scorecard + noema-review + required-workflow-bootstrap + coverage-evidence + opencode-review +``` + +The historical snapshot had 14 named contexts. Classic branch protection blocks merge until every +named context reports a conclusion; a workflow-file `on:` filter that causes +GitHub to never queue that job at all leaves its context **Pending forever** +here, which is worse than "not required" -- it is an unmergeable PR with no +path to a passing state short of a repository-admin exemption. + +Putting the two together: a `paths-ignore:` on a required workflow's trigger +is **inert in 40+ repositories and merge-breaking in `.github`**. Neither +side of that trade is acceptable, so trigger-level path filtering on a +required workflow is a **no-go**. + +### The one documented exception: `strix.yml` + +`strix.yml` already carried `paths-ignore:` on both its `push` and +`pull_request_target` triggers before this phase. A live run-event census +(last 100 runs per repository) shows why it is safe to *keep*, not a +precedent to *extend*: + +``` +.github strix.yml : pull_request_target 93, push 5, repository_dispatch 2 (native runs) +bandscope strix.yml : 0 native runs -- every Strix run there is ruleset-injected +``` + +`.github`, `noema`, and `IRT-bibliography-set` are excluded from ruleset +`18156473` (see the exclude list above), so *their* `strix.yml` runs are +genuinely native and the trigger-level filter is genuinely evaluated there -- +it is a real, free saving today. In every other repository the filter is +simply never consulted, exactly as with the other required workflows. The +comments on both `paths-ignore:` blocks in `strix.yml` now say this +explicitly instead of implying the filter applies to PRs everywhere. + +### The `codeql-pr.yml` matrix hazard + +CodeQL's `analyze-head`/`analyze-merge` jobs derive `strategy.matrix` from a +separate `detect-languages` job's output. Run `33708209086` in `.github` +proved a job-level `if:` skip on a matrix-consuming job does **not** publish +correctly-named skipped legs when the matrix itself never resolved: + +``` +Detect CodeQL languages completed skipped +CodeQL compatibility analysis (${{ matrix.language }}) completed skipped <-- literal, unexpanded +CodeQL merge preview (${{ matrix.language }}) completed skipped +``` + +The two required contexts `CodeQL compatibility analysis (actions)` and +`(python)` were never created for that run -- an unmergeable PR under +`.github`'s classic protection. Whether a job-level `if:` on `analyze-head` +specifically (whose matrix *is* resolvable, since `detect-languages` itself +is never skipped) would publish correctly is undocumented and unverified +either way, so the safe default was chosen: gate the five expensive **steps** +inside `analyze-head` instead of the job. The job still runs (~20s), +succeeds, and the check-run names are never in question because the matrix +resolved normally. `analyze-merge`'s `CodeQL merge preview (...)` context is +required nowhere, so it keeps a job-level guard -- and doubles as the future +observation point: if its skipped legs publish as `CodeQL merge preview +(actions)`/`(python)` rather than the literal template, `analyze-head` can be +flipped to a one-line job-level `if:` in a follow-up, with real evidence +behind it instead of an assumption. + +### Independent, pre-existing blocker (not fixed by this repair) + +Every ruleset-injected `CodeQL PR` run in every covered repository observed +during this phase is `startup_failure` with **zero check runs created** +(`bandscope` run `33707165672`, 2026-09-03T02:18:51Z, and equivalents in +`naruon`, `aFIPC`, `pg-erd-cloud`, `xtrmLLMBatchPython`). Every other +ruleset workflow in the same repositories enqueues normally. Gating CodeQL's +runner admission (this repair) saves nothing in those repositories until that +separate startup failure is fixed -- it is a higher-priority, independent +issue and is called out as an owner action, not addressed here. + +## The mechanism this repair uses instead + +A `changed-scope` job, inserted as the first job in +`security-scan.yml`, `sast-semgrep.yml`, `strix.yml`, `scorecard-pr.yml`, and +`osv-scanner-pr.yml` (byte-identical apart from one `if:` line -- see +`tests/test_docs_only_pr_runner_admission.py`), reads the PR's changed-file +list via `gh api repos/.../pulls//files` and publishes two boolean +outputs (`code`, `deps`). Downstream jobs add `needs: changed-scope` and AND +an output check into their existing `if:`. `codeql-pr.yml`'s +`detect-languages` job gained the same classifier as one more step, feeding +step-level guards on `analyze-head` and a job-level guard on `analyze-merge`. + +This works in both contexts that trigger-level filtering could not satisfy +simultaneously: + +- **Ruleset-injected repos:** the ruleset ignores `on:` filters, but it + cannot skip a job's own `if:` evaluation -- that happens inside the run + GitHub Actions actually executes, after admission, using that target + repository's real PR event payload. +- **`.github` classic protection:** the job **always runs** (its own `if:` + is event-based, not output-based) and always reports a conclusion -- + `success` when in scope, `skipped` when not -- so the named context is + never left Pending. + +The classifier fails **open**: an unreadable, empty, or truncated file list +(including one that doesn't match the PR's own `changed_files` count, which +GitHub caps at 3000 entries per page) scans everything. Every one of the five +workflows keeps at least one job with no `needs:` and no output-dependent +`if:` (the `changed-scope` job itself, `cancel-superseded-pr-runs` also +qualifying in `strix.yml`), so a fully-skipped run still concludes +`success`, not the undocumented `skipped` conclusion. + +`LICENSE.*` was deliberately **not** reused from `strix.yml`'s existing +doc-pattern list: it matches `LICENSE.py`, which is executable. The +classifier's doc/image pattern list uses the explicit names `LICENSE`, +`LICENSE.txt`, `COPYING`, `COPYING.txt`, `NOTICE`, `NOTICE.txt` instead +(`.md`/`.rst` variants are already covered by the `*.md`/`*.rst` globs). No +`*.svg` (carries script), no bare `*.txt`, no `CODEOWNERS`; the match is +case-sensitive (`README.MD` scans). Every ambiguity resolves toward +scanning. + +## Verification + +`tests/test_docs_only_pr_runner_admission.py` is the RED-first contract: +byte-identical gate copies, an identical and safe doc-pattern line shared +with `codeql-pr.yml`'s classifier step, `runs-on: ubuntu-24.04` on every gate +job, no trigger-level `paths`/`paths-ignore` on any of the nine other +required-adjacent workflows, the `closed`-guard-plus-needs-output shape on +every gated job, `codeql-pr.yml`'s step-vs-job gating split, and the +always-admitted job in each of the five gate workflows. + +Post-merge, the operational proof is a docs-only PR in one ruleset-covered +repository: `changed-scope` (and `detect-languages` for CodeQL) succeed while +`strix` / `Semgrep (multi-language SAST)` / `osv-scan` / `trivy-fs` / +`scorecard` report `skipped`, and the **run conclusion** is `success`, not +`skipped`. + +## Safety boundary + +This repair does not weaken any scanner's actual coverage. Every gate +defaults toward scanning on any ambiguity or read failure. The backstops +that make each skip safe are unchanged: `scheduled-security-scan.yml` +(push + weekly cron) and `scorecard-analysis.yml` (push + weekly cron) still +run full, unfiltered scans of the default branch. `secret-scan.yml` is +intentionally untouched (already diff-scoped and cheap; a leaked key in a +`README.md` is the canonical case a doc-only skip would otherwise miss). +`codeql-pr.yml`'s `detect-languages` job keeps its unconditional `if:` +because gating it would destroy the two required CodeQL contexts, per the +matrix hazard above. diff --git a/docs/doctoring/reusable-default-branch-scorecard-owner-20260903.md b/docs/doctoring/reusable-default-branch-scorecard-owner-20260903.md new file mode 100644 index 0000000000..cc3cb62f9f --- /dev/null +++ b/docs/doctoring/reusable-default-branch-scorecard-owner-20260903.md @@ -0,0 +1,130 @@ +# Reusable default-branch Scorecard owner — 2026-09-03 + +## Incident and buyer-visible risk + +Repository-local `scorecard-analysis.yml` files in `ContextualWisdomLab/wardnet` and +`ContextualWisdomLab/semantic-data-portal` repeat the same OSSF Scorecard, SARIF filtering, and upload +implementation. Open deletion PRs `wardnet#160` and `semantic-data-portal#93` assumed the organization-required +`scorecard-pr.yml` fully replaced them. That assumption is false: the required workflow supplies pull-request +evidence, while the local workflows supply default-branch push and weekly scheduled evidence. Deleting them +without a successor would stop branch-history and scheduled SARIF refresh. + +The customer consequence is stale supply-chain posture after a merge: a pull request could be scanned before +landing, while the authoritative default branch and its later dependency/configuration drift receive no +corresponding Scorecard result. + +## Owner decision + +`ContextualWisdomLab/.github/.github/workflows/scorecard-analysis.yml` is the canonical implementation owner for +default-branch Scorecard analysis. It preserves its own `push` and `schedule` triggers and adds `workflow_call` +for product repositories. Consumers retain only the trigger and permission boundary that GitHub cannot express +centrally across independent repositories. + +The called workflow uses the caller's `github` context and `actions/checkout` therefore checks out the caller +repository. The caller's `GITHUB_TOKEN` permissions cannot be elevated by the called workflow, so each caller +must explicitly grant the required permissions. Consumers must pin the reusable workflow to the full immutable +**central merge commit SHA**, never `main`, another mutable branch, or an open PR head. + +## Canonical thin caller after this owner PR lands + +Replace `` and `` only after the central PR is merged: + +```yaml +name: Scorecard analysis + +on: + push: + branches: [""] + schedule: + - cron: "30 1 * * 6" + +permissions: read-all + +jobs: + scorecard_analysis: + permissions: + security-events: write + id-token: write + contents: read + issues: read + pull-requests: read + checks: read + uses: ContextualWisdomLab/.github/.github/workflows/scorecard-analysis.yml@ +``` + +Do not add `runs-on`, `steps`, copied Scorecard logic, inherited secrets, or a second concurrency group to the +caller job. The called owner already coalesces same-ref invocations; a caller-side group with an overlapping +identity could cancel its own called workflow. + +## Concurrency decision + +This PR's own earlier draft reasoned that GitHub concurrency admission follows event arrival order, not commit +ancestry, and scoped the group by `${{ github.repository }}`, `${{ github.ref }}`, and `${{ github.sha }}` with +`cancel-in-progress: true` so only duplicate invocations of the same immutable revision could cancel one another. +That reasoning is sound in isolation, but `.github#1768` (merged to `main` before this PR's own branch caught +up) had independently added a *different*, already-reviewed concurrency group to this same file: scoped by +`${{ github.ref }}` only, `cancel-in-progress: false`, so an in-flight scan for an older commit always finishes +and uploads that commit's SARIF evidence rather than being cancelled, and a burst of pushes queues (GitHub's +default single-pending-successor behavior) instead of running unboundedly in parallel. + +**Merging this branch as-is produced two `concurrency:` keys in one YAML mapping -- a real bug, not a stylistic +duplication: YAML resolves a repeated mapping key to its last occurrence, so the SHA-scoped block was silently +discarded at parse time regardless of author intent.** The two designs are also structurally incompatible as a +single `concurrency:` block, not just redundant: SHA-scoping gives every distinct commit its own group, which +means NOTHING ever queues behind anything else -- restoring the unbounded-concurrent-scans problem `#1768` +exists to prevent. Given this organization's standing priority of reducing GitHub Actions queue congestion +(a plan-level 60-job ceiling shared across the whole org), `#1768`'s ref-scoped, cancel-false group was kept as +authoritative and this PR's SHA-scoped block was removed. The narrower concern the SHA-scoped design addressed +(a delayed duplicate event for the exact same commit) remains a real, if much rarer, residual risk -- not +closed here. + +This also differs deliberately from the merge scheduler's integrated current-head coalescing step: that step performs queue-cleanup mutation, so its active worker must finish and only the latest pending trigger is retained. + +## TDD and rollout evidence + +- RED `76617d0a1f4bd0126d0e610362328ace2dd02612`: contract requires `workflow_call`, preserved push/schedule, + reusable ownership, immutable action pins, credential hygiene, and SARIF upload behavior while the owner + workflow still lacks the reusable contract. +- GREEN `aaf0fa5241348648e43618f949f44b82028abaa2`: owner workflow implements the initial reusable contract. +- Review RED `ef88c78aa64b6922f50d4a6a3e34f1900d04694f`: parsed-YAML contracts require the exact-SHA concurrency + boundary while production still groups only by repository/ref. The same commit replaces comment-sensitive + substring checks with structural YAML assertions. +- Review GREEN `7f99d560e8eaa9ab2cec46600b3321e9b0700669`: production adds the exact source SHA to the group and records + the owner boundary for any future cross-revision cleanup. +- Focused reconstructed exact-content test before review: `3 passed`. +- **Post-review correction, before merge:** `.github#1768` landed its own, incompatible concurrency group for + this same file while this PR's branch was still in flight (see "Concurrency decision" above). The exact-SHA + group GREEN commit above is accurate as a record of this PR's own development, but is NOT the state that + merged -- the final concurrency block keeps `#1768`'s ref-scoped, `cancel-in-progress: false` group instead. +- Rollout remains incomplete until the central PR merges and each consumer pins the resulting merge SHA. + +## Consumer acceptance criteria + +For each consumer repository: + +1. Re-fetch the default branch and deletion-PR exact head. +2. Replace local implementation with the thin caller pinned to the central merge SHA. +3. Preserve the repository's actual default branch and weekly schedule. +4. Update repository documentation that names the local implementation. +5. Prove a default-branch push or governed canary invokes the central workflow in the caller context, checks out + the consumer commit, produces Scorecard output, and attempts SARIF upload under the declared permissions. +6. Confirm the central PR-required Scorecard and default-branch caller do not both trigger for the same event. +7. Confirm a delayed older-revision event cannot cancel a newer-revision scan. +8. Merge through ordinary protection unless the exact central queue-control chicken-and-egg condition applies. + +`wardnet#160` and `semantic-data-portal#93` remain open repair branches until these criteria are satisfied; they +must not be closed merely to reduce the PR count. + +## References + +GitHub. (2026). *Reusing workflow configurations*. GitHub Docs. +https://docs.github.com/actions/reference/workflows-and-actions/reusing-workflow-configurations + +GitHub. (2026). *Reuse workflows*. GitHub Docs. +https://docs.github.com/actions/how-tos/reuse-automations/reuse-workflows + +GitHub. (2026). *Control the concurrency of workflows and jobs*. GitHub Docs. +https://docs.github.com/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency + +Open Source Security Foundation. (2026). *OSSF Scorecard action*. GitHub. +https://github.com/ossf/scorecard-action diff --git a/docs/doctoring/review-repair-quality-workflow-identity.md b/docs/doctoring/review-repair-quality-workflow-identity.md index c8048ef72f..d3b38b5b10 100644 --- a/docs/doctoring/review-repair-quality-workflow-identity.md +++ b/docs/doctoring/review-repair-quality-workflow-identity.md @@ -1,5 +1,17 @@ # Review-repair quality workflow identity RCA +## 2026-09-04 consolidation + +The standalone compatibility workflow has now been retired. Its contract suite +and path ownership moved into +`.github/workflows/agent-review-runtime-quality-ci.yml`, where the existing +affected-suite selector runs it only for review-repair changes. This removes one +independent checkout, Python setup, and dependency-install job per matching PR +without changing the repair worker, scheduler, permissions, or model routing. +The consolidated PR workflow keeps the required +`agent-review-runtime-quality-${{ github.repository }}-${{ github.event.pull_request.number }}` +group with `cancel-in-progress: true`. + ## Status Recorded 2026-09-01 against protected `ContextualWisdomLab/.github` `main@b4f7b082536d2be8dceab0a40a484161b50e5acd` and repair PR #1573. diff --git a/docs/doctoring/scheduler-rate-limit-fail-fast-boundary-20260903.md b/docs/doctoring/scheduler-rate-limit-fail-fast-boundary-20260903.md new file mode 100644 index 0000000000..93ad66ac95 --- /dev/null +++ b/docs/doctoring/scheduler-rate-limit-fail-fast-boundary-20260903.md @@ -0,0 +1,119 @@ +# Scheduler primary rate-limit 무수면 경계 + +- 기준 저장소: `ContextualWisdomLab/.github` +- 구현 기준: PR #1803 +- 확인 시점: 2026-09-03 KST +- 상태: exact-head 검증 대상 + +## 장애 장면 + +`pr_review_merge_scheduler.py`는 GitHub App installation의 공유 primary rate limit이 +소진되면 REST 또는 GraphQL 요청을 최대 네 번 시도했다. 재시도 전마다 +`GET /rate_limit`을 읽고 최대 60초를 기다렸으므로 하나의 논리 API 호출이 세 번의 +대기 끝에 약 180초 동안 runner를 점유할 수 있었다. + +또한 `.github/workflows/opencode-review-dispatch.yml`의 승인 후 best-effort caller는 +scheduler CLI의 non-zero exit를 최대 세 번 다시 실행하며 5·10·15초를 추가로 +기다렸다. helper 내부 대기만 제거하고 rate-limit을 exit 1로 반환하면 이 caller가 +약 30초를 계속 점유하므로 독립 리뷰에서 불완전한 수리로 판정됐다. + +반대로 모든 caller에서 rate-limit을 exit 0으로 바꾸면 조직 sweep의 rate-limit stop +signal을 잃는다. core는 mid-scan rate-limit을 non-zero로 전파해 현재 repository에서 +rotation을 멈추고 같은 exhausted bucket으로 뒤 repository를 계속 읽지 않도록 한다. +따라서 defer outcome은 caller별 책임을 구분해야 한다. + +## 책임 분리 + +기존 구현은 `scripts/ci/pr_review_merge_scheduler_core.py`로 이름을 명확히 분리한다. +기존 `scripts/ci/pr_review_merge_scheduler.py`는 외부 workflow command와 Python import를 +보존하는 안정된 facade다. + +- core: PR 조회·review 판단·dispatch·merge·branch update의 domain logic +- facade: 기존 CLI/import 계약, wildcard export, 운영 rate-limit retry/defer policy + +facade는 module proxy와 `__all__`을 사용해 기존 attribute access, +`monkeypatch.setattr(scheduler, ...)`, wildcard import를 core에 연결한다. 따라서 기존 +소비자 API와 유효한 단위 테스트를 폐기하지 않는다. + +## 선택한 정책 + +모든 운영 CLI 호출에서 facade는 다음 transport 정책을 적용한다. + +- `API rate limit exceeded` primary exhaustion은 원 요청 한 번 뒤 즉시 중단한다. +- reset 시각 확인을 위한 `GET /rate_limit` 추가 호출을 하지 않는다. +- primary rate-limit 경로에서 `time.sleep`을 호출하지 않는다. +- JSON 절단, 일시적인 server error, timeout 등 통신 장애에는 최대 네 번의 짧은 + 1·2·4초 재시도를 유지한다. + +rate-limit이 facade 경계까지 전파됐을 때 outcome은 caller identity로 분기한다. + +### OpenCode 승인 후 best-effort follow-up + +다음 조건을 모두 만족할 때만 rate-limit을 수락된 defer로 처리한다. + +- `GITHUB_WORKFLOW`가 `OpenCode Review Dispatch` +- `--max-prs 1` +- `--review-dispatch-limit 0` +- `--merge-mode direct_or_auto` +- `--pr-number`, `--no-trigger-reviews`, `--enable-auto-merge`, + `--no-update-branches`가 모두 존재 + +이 경우 `scheduler_outcome=deferred_rate_limit`과 +`retry_owner=Required PR Review Merge Scheduler heartbeat` receipt를 stderr와 GitHub +step summary에 남기고 exit 0을 반환한다. 현재 follow-up caller는 non-zero에서만 +5·10·15초를 기다리므로 실제 외부 sleep은 첫 호출에서 종료된다. PR-event와 scheduled +scheduler가 authoritative retry owner라는 caller source의 기존 설명과도 일치한다. + +### 조직 sweep과 다른 caller + +같은 rate-limit이라도 위 signature가 아니면 exit 1을 유지한다. 특히 +`Required PR Review Merge Scheduler` 조직 sweep은 첫 rate-limit repository에서 +rotation을 멈추고 다음 heartbeat로 defer하는 기존 #1245 계약을 보존한다. +워크플로 이름만 같거나 인자 일부만 비슷한 호출도 accepted defer로 오인하지 않는다. +rate-limit이 아닌 RuntimeError도 항상 exit 1이다. + +caller의 대형 workflow 파일을 부분 내용만으로 통째로 재작성하면 동시 delta를 잃을 +위험이 컸다. 따라서 이번 수리는 stable CLI outcome contract에서 실제 30초 점유를 +제거한다. 후속 owner lane에서는 caller의 도달 불가능한 retry loop 자체도 삭제해 +source를 단순화한다. + +## RED와 GREEN 계약 + +`tests/test_scheduler_rate_limit_fail_fast_entrypoint.py`가 다음을 고정한다. + +1. GraphQL primary rate-limit은 요청 1회, sleep 0회로 실패한다. +2. REST primary rate-limit은 요청 1회, sleep 0회로 실패한다. +3. facade는 `/rate_limit` endpoint를 호출하지 않는다. +4. 정확한 OpenCode follow-up signature는 exit 0, typed receipt, sleep 0으로 defer한다. +5. 조직 sweep rate-limit은 exit 1을 유지한다. +6. workflow 이름만 맞고 signature가 다르면 exit 1을 유지한다. +7. rate-limit이 아닌 RuntimeError는 exit 1을 유지한다. +8. 일반 server error는 1초 뒤 한 번 재시도해 성공할 수 있다. +9. 기존 facade monkeypatch와 wildcard import가 core API를 보존한다. +10. dispatch source marker는 facade 문구만이 아니라 core 구현에도 존재한다. + +GitHub exact-head checks가 runner 배정 전 queued이면 GREEN으로 간주하지 않는다. + +## 영향과 후속 조치 + +OpenCode 승인 후 rate-limit 한 건의 helper 내부 최악 wait는 약 180초에서 0초로, +caller의 실제 추가 wait는 약 30초에서 0초로 줄어든다. 원 요청·reset lookup을 합친 +최대 7회 API 호출은 원 요청 1회로 줄어든다. 조직 sweep의 stop-and-defer signal은 +그대로 남는다. + +아직 별도 원인이 남아 있다. + +- caller source에 남은 도달 불가능한 `for attempt`와 `sleep` 구문 삭제 +- 승인 visibility 확인 step의 최대 30초 polling +- org sweep 안의 중복 Actions run inventory와 stale cancellation +- Required OpenCode·Noema·Strix의 current-head admission과 + `cancel-in-progress: true` +- 동일 PR 상태를 여러 event가 깨우는 scheduler trigger fan-out + +이들은 #1796, #1706, #712의 focused successor lane에서 계속 추적한다. + +## Rollback + +문제가 생기면 facade와 core 분리, caller-scoped typed defer contract를 같은 revert로 +복원한다. core만 삭제하거나 facade만 옛 monolith로 되돌리면 import와 outcome 경계가 +갈라지므로 부분 rollback은 하지 않는다. diff --git a/docs/doctoring/scheduler-stale-headrefoid-cancellation.md b/docs/doctoring/scheduler-stale-headrefoid-cancellation.md index 8f526516e7..05ce39db87 100644 --- a/docs/doctoring/scheduler-stale-headrefoid-cancellation.md +++ b/docs/doctoring/scheduler-stale-headrefoid-cancellation.md @@ -1,5 +1,9 @@ # Scheduler stale-head cancellation: fail closed at the destructive boundary +> Updated 2026-09-04. The Python scheduler's own exact-PR cancellation guards +> remain active. The separate cross-repository shell cancellation helper named +> below was retired with the duplicate org-sweep queue-hygiene path. + ## Incident On 2026-09-02, `ContextualWisdomLab/naruon#1528` had Strix run `33581213829` diff --git a/docs/doctoring/scheduler-target-list-drift-20260902.md b/docs/doctoring/scheduler-target-list-drift-20260902.md new file mode 100644 index 0000000000..095f9d2193 --- /dev/null +++ b/docs/doctoring/scheduler-target-list-drift-20260902.md @@ -0,0 +1,89 @@ +# Doctoring record: scheduler target-list drift (2026-09-02) + +## Incident + +`hourly-review-repair.yml`'s per-cron `target_repository` matrix and the +`OPENCODE_REPOSITORY_DISPATCH_TARGETS` repository variable (which gates +`ALLOWED_TARGET_REPOSITORIES` in `pr-review-merge-scheduler.yml` / +`pr-review-fix-scheduler.yml`, and the agent-mention dispatch allowlist) are +two independently hand-maintained lists of "repositories legitimately +targetable by an OpenCode-driven dispatch." They have no structural link: +adding a repository to one does not add it to the other. + +This caused three real, silent failures, all discovered and fixed the same +day: + +- `governance-risk-compliance` — added to the hourly matrix (run + `.github/actions/runs/33524178483/job/99910668839`, 2026-09-01) before the + variable was updated; every hourly heartbeat failed with `##[error]Scheduler + target repository is not allowlisted: ContextualWisdomLab/governance-risk-compliance.` + A prior fix attempt (commit `7bf98d0`) hardcoded the repository name + directly into both scheduler workflows as a "temporary propagation bridge" + instead of fixing the variable — this violated this repo's own thin-caller + convention (`CLAUDE.md`: "Product hourly callers stay thin. Do not + hard-code ... into `pr-review-fix-scheduler.yml`") and broke + `test_no_target_repository_is_hard_coded_in_the_shared_scheduler` on `main`. + Fixed properly in `contextual-orchestrator#1028`'s sibling PR here + (`fix(scheduler): admit governance-risk-compliance via the org variable, not + a hardcode`, #1743): added the repository to the variable directly, removed + the hardcode. +- `nonnest2` and `quarantine-sandbox-runtime` — found by diffing the hourly + matrix's target list against the live variable's value while scoping this + fix: both were present in the hourly matrix (present since the original + 18-file-to-1 consolidation, ADR-0021) but absent from the variable, + meaning their hourly heartbeat had been failing closed the same way, + undetected because the queue backlog this session was separately + investigating (a hard 60-concurrent-job org plan limit, confirmed via the + GitHub Actions Settings UI) meant these runs weren't being watched + individually. Fixed the same way: added both to the variable. + +## Root cause + +Not a logic bug in either scheduler — `target_allowed` fails closed exactly +as designed when a target isn't in the allowlist, which is correct behavior +for an *actually* unauthorized target. The defect is that there is no +mechanism keeping the two lists in sync, and no test catching a PR that adds +a repository to one list without the other. + +## Fix + +- `scripts/ci/opencode_repository_dispatch_targets.json` — a new, + hand-maintained mirror of `OPENCODE_REPOSITORY_DISPATCH_TARGETS`'s live + value (there is no API to commit a repository variable's value to source + control, so this file is deliberately a mirror, not a generator — whoever + updates the live variable updates this file in the same PR, per the file's + own header comment). +- `tests/test_hourly_review_repair_callers.py::test_every_hourly_caller_target_is_in_the_dispatch_targets_mirror` — + asserts every `target_repository` in `hourly-review-repair.yml`'s + `_EXPECTED_TARGETS` (the existing, already-tested canonical model of the + workflow's `case` statement) is present in the mirror. A future PR that + adds a repository to the hourly matrix without also updating the mirror + (and, by the mirror's own documented discipline, the live variable) now + fails this test at review time instead of failing the next hourly + heartbeat silently. + +## What this does not do + +This does not verify the mirror file's contents actually match the live +variable's *current* value — that would require a network call to the +GitHub API at test time, which this repo's offline `pytest tests` suite +deliberately does not do (see `pyproject.toml`'s `pythonpath` setup; every +other contract test in this module is a pure file-content assertion). A +mismatch between the mirror and the live variable (e.g. someone runs `gh +variable set` without updating this file, or vice versa) is not caught by +this test — only a mismatch between the *workflow matrix* and the mirror is. +Closing that remaining gap (verifying the mirror against the live variable) +needs either a step in an existing regularly-running workflow or a documented +manual verification command, and was deliberately left out of this fix to +keep it a pure test addition with zero production-workflow risk; see the +open item below. + +## Follow-up (not done here, deliberately out of scope for this fix) + +Add a live-verification step (in an existing workflow, not a new one, per +this session's org-culture reasoning: prefer a loud contract-test-style +failure a human must resolve with an explicit commit over an +auto-mutating workflow that "magically" fixes drift) that fetches +`OPENCODE_REPOSITORY_DISPATCH_TARGETS`'s live value and fails loudly if it +diverges from `scripts/ci/opencode_repository_dispatch_targets.json`. Left +open pending a decision on which existing workflow should host that step. diff --git a/docs/doctoring/startup-failure-and-strix-concurrency-20260904.md b/docs/doctoring/startup-failure-and-strix-concurrency-20260904.md new file mode 100644 index 0000000000..e710eb5d1f --- /dev/null +++ b/docs/doctoring/startup-failure-and-strix-concurrency-20260904.md @@ -0,0 +1,53 @@ +# Startup failure recovery and Strix concurrency repair + +## Evidence + +The organization-wide REST census on 2026-09-04 covered all 74 visible +ContextualWisdomLab repositories. It found no new `startup_failure` created +after central main `07db37e5e42c63ba40ac66f22ef74e4f8836ce9a`, confirming that the +required-workflow CodeQL prohibition is no longer firing. The census still +found six non-CodeQL startup failures on unchanged heads of two open pull +requests. Their REST job lists are empty. A live +`POST /actions/runs/32985871408/rerun` probe also returned +`403 This workflow run cannot be retried`, so neither job nor run retry can +recover them. + +The same audit found that `strix.yml` admitted provider jobs directly into a +job group that included `github.event_name`, which put +`pull_request_target` and `repository_dispatch` evidence for the same +repository and pull request in different queues. It also used +`cancel-in-progress: false`, preserving duplicate scanner work. + +## Decision + +The scheduler now considers only the newest run for each workflow on the exact +current head. When any latest PR run has `startup_failure`, it reuses the +existing guarded same-tree restamp operation to create one new head and one +fresh `synchronize` event. A newer queued or completed run suppresses +recovery, and a head whose latest commit is already the recovery restamp is not +restamped again. The former direct-CodeQL required workflow was excluded while +its platform prohibition remained. The dispatch-and-poll architecture has +since removed all `github/codeql-action` use from the required entrypoint, so +CodeQL now uses the same guarded recovery path as every other pre-job failure. +The PR head is re-read immediately before mutation, and the operation remains +restricted to same-repository branches plus a credential that GitHub permits to +start workflows. + +Strix now validates event metadata against the live pull request before the +provider job can enter one `strix-security-scan--` +group shared by native PR and repository-dispatch evidence, with +`cancel-in-progress: true`. A delayed stale event is skipped before concurrency +and therefore cannot cancel newer evidence. Push and schedule runs receive a +unique run-id admission output, so they neither cancel PR evidence nor one +another. Workflow-level concurrency was deliberately not used because GitHub +applies it before any live-head admission job can run and does not guarantee +concurrency ordering. + +## Verification + +- `python -m pytest -q tests/test_pr_review_merge_scheduler.py -k 'startup_failures or startup_failure'` +- `bash scripts/ci/test_strix_quick_gate.sh` +- `actionlint -color never .github/workflows/strix.yml` + +The review sidecar and its direct contract tests are intentionally outside this +change. diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 7c55c6fbab..88f6cc4deb 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -1,6 +1,6 @@ # ContextualWisdomLab central required workflow rollout -Updated: 2026-08-28 KST +Updated: 2026-09-04 KST ## Decision @@ -12,8 +12,8 @@ Use an organization repository ruleset instead of copying workflow files into ea - Target: branch rules on every repository's default branch (`repository_name.include=["~ALL"]`, `ref_name.include=["~DEFAULT_BRANCH"]`) - Required workflow source repository: `ContextualWisdomLab/.github` - Required workflow source repository ID: `1274066402` -- Active required workflow paths: - - `.github/workflows/close-empty-pr.yml` +- Canonical required workflow paths (seven entries): + - `.github/workflows/codeql-pr.yml` - `.github/workflows/noema-review.yml` - `.github/workflows/opencode-review.yml` - `.github/workflows/pr-review-merge-scheduler.yml` @@ -21,16 +21,18 @@ Use an organization repository ruleset instead of copying workflow files into ea - `.github/workflows/strix.yml` - `.github/workflows/sast-semgrep.yml` - Required workflow ref: `refs/heads/main` -- Last verified workflow implementation base commit: `050e6d59b0de9e62c8413d5f8f26f4f2f9ebea09` (`#584`) +- Last verified workflow implementation base commit: `050e6d59b0de9e62c8413d5f8f26f4f2f9ebea09` (`ContextualWisdomLab/.github#584`) - Required workflow trigger support: `pull_request`, `pull_request_target`, `push`, `workflow_run` -The required-workflow implementation is current through merged `.github#584`. -The ruleset points at `.github@main`; if live organization ruleset inspection -reports another ref, treat that as operations drift and restore ruleset -`18156473` to the current `main` head. +The required-workflow implementation is current through merged `ContextualWisdomLab/.github#584` plus the later governance and security repairs recorded below. The ruleset points at `.github@main`; if live organization ruleset inspection reports another ref, treat that as operations drift and restore ruleset `18156473` to the current `main` head. This keeps Strix security evidence, OpenCode and independent Noema review evidence, and merge/update automation sourced from the central `.github` repository. Target repositories do not need local copies of these workflows for the organization required workflow rule, and new repositories inherit the rule without a repository-name list update. +Empty non-draft pull requests are closed by the existing metadata-only +`pr-review-merge-scheduler.yml` scan after an exact-head REST recheck. The +former standalone required workflow was removed so the same PR no longer +consumes a second runner for the same metadata decision. + The central `security-scan.yml` and `sast-semgrep.yml` pull-request triggers are base-ref agnostic. They therefore also run for stacked pull requests targeting a feature branch; the organization ruleset's protected-ref scope remains an @@ -101,26 +103,56 @@ Keep the OpenCode required workflow active only while the central workflow keeps ## Code scanning required workflow posture -The central `.github/workflows/codeql-pr.yml`, `.github/workflows/scorecard-pr.yml`, -and `.github/workflows/osv-scanner-pr.yml` workflows supply PR-head and merge-preview -code scanning analyses for ruleset `18156473` `code_scanning` (CodeQL, Scorecard, -osv-scanner). They trigger on pull requests to `main`, `master`, and `develop` so -Git Flow repositories on `develop` inherit the same merge gate as GitHub Flow repos. - -CodeQL merge preview checks out `refs/pull//merge` and uploads SARIF with -`sha: pull_request.merge_commit_sha` because the ruleset evaluates that commit, -not the ephemeral merge ref OID. - -Repository-local `codeql.yml` push/default-branch scans may remain for branch -history, but PR merge gates should rely on the central `codeql-pr.yml` workflow. - -### Repository-local CodeQL inventory (2026-07-04) - -Org audit of default-branch workflow files. Repos without any local CodeQL -workflow depend entirely on central `codeql-pr.yml` once ruleset `18156473` -includes that path; they are the most exposed to -`Code scanning is waiting for results from CodeQL` until the ruleset update -lands. +**Correction (2026-09-04): restore the dispatch-safe CodeQL entrypoint.** +The 2026-09-03 removal was correct for the old workflow, which called +`github/codeql-action` directly and always failed at startup. The current +`codeql-pr.yml` contains no such action. It validates the exact live head, +dispatches the scan to the native `codeql-scan-dispatch.yml`, and waits for an +app-authored `codeql-dispatch/` status. Ruleset `18156473` must require +this dispatch-safe entrypoint after its audit contract reaches protected main. +The scheduler may then same-tree restamp a future CodeQL `startup_failure` just +like any other pre-job failure. Native default setup remains a repository-local +safety net; it does not replace the central required gate. Do not add any +workflow that invokes `github/codeql-action` directly to a required-workflow +ruleset. +The org's `default_for_new_repos: "all"` policy (configuration `17`, "GitHub recommended") is supposed to +make this automatic for every newly created repository, but item 41's investigation confirmed it is +empirically unreliable for this org: 11 non-fork repositories created between 2026-05-09 and 2026-08-18 — +well after that policy's own `updated_at` of 2025-03-04 — never received it. Closing that specific gap (a +periodic reconciliation sweep, vs. this org's stated aversion to more scheduled workflows for rate-limit +reasons) is recorded as still open in `docs/product-technical-gap-baseline.md`'s item 41 entry, not decided +here. + +The central `.github/workflows/security-scan.yml` supplies PR-head OSV and Scorecard evidence in one +required workflow. The former standalone PR workflows were retired after the live ruleset and `.github` +classic branch protection stopped requiring their duplicate contexts. +`.github/workflows/codeql-pr.yml` used the same trigger shape and merge-preview +technique (checking out `refs/pull//merge` and uploading SARIF with +`sha: pull_request.merge_commit_sha` because the ruleset evaluates that commit, not +the ephemeral merge ref OID) before its removal above. + +Repository-local CodeQL and native default setup may coexist with the central +gate only when they do not compete to upload the same SARIF. The central native +dispatch handler analyzes the target head without making the target repository's +default-setup upload path its source of truth. + +### Repository-local CodeQL inventory (2026-07-04) — HISTORICAL, superseded 2026-09-03 + +**This entire subsection describes a plan that did not work and is not +current guidance.** It assumed `codeql-pr.yml` would become a functioning +central required check once ruleset `18156473` included it; the "Correction +(2026-09-03)" note under "Code scanning required workflow posture" above +explains why that assumption was wrong — `codeql-action` cannot run inside a +required workflow at all, so `codeql-pr.yml` was removed from the ruleset, +not fixed. "Centralizing through `codeql-pr.yml` fixes every inherited +repository in one ruleset change" (below) never happened and never could. +Coverage for repositories without a local CodeQL workflow now comes from +GitHub's native `code-scanning/default-setup` instead (see the 2026-09-03 +"Evidence from this rollout" entry) — do not read the table below as +"repositories still needing the ruleset update to land"; treat it only as a +2026-07-04 point-in-time snapshot of which repositories had a local `codeql.yml`. + +Org audit of default-branch workflow files as of 2026-07-04. | Repository | Default branch | Local CodeQL workflow | PR trigger | merge_commit_sha SARIF | | --- | --- | --- | ---: | ---: | @@ -130,12 +162,36 @@ lands. | `pg-erd-cloud` | `main` | `codeql.yml`, `codeql-backfill.yml` | yes (`codeql.yml`) | no | | `xtrmLLMBatchPython` | `develop` | `codeql.yml` | yes | no | | `naruon` | `develop` | `codeql.yml` | yes (temporary; PR `#916` retires PR trigger) | yes (repo-local interim fix) | -| all other public non-fork org repos | varies | none observed | — | — | - -No repository-local PR CodeQL workflow besides `naruon` uploads merge-preview -SARIF on `merge_commit_sha`. Centralizing through `codeql-pr.yml` fixes every -inherited repository in one ruleset change; per-repo deletion of PR triggers is -optional cleanup to avoid duplicate scans. +| all other public non-fork org repos | varies | none observed as of 2026-07-04 | — | — | + +No repository-local PR CodeQL workflow besides `naruon` uploaded merge-preview +SARIF on `merge_commit_sha` as of this 2026-07-04 snapshot. The plan at the +time was that centralizing through `codeql-pr.yml` would fix every inherited +repository in one ruleset change; per-repo deletion of PR triggers was +intended as optional cleanup to avoid duplicate scans. Neither happened — +see the historical marker above. + +### Audit tool coverage + +`scripts/ci/audit_central_required_workflows.py` defines all nine canonical +required workflow paths (`codeql-pr.yml` deliberately excluded, per the +2026-09-03 correction above) and treats the live policy as an exact +inventory: every required path must appear exactly once with repository id +`1274066402` and `refs/heads/main`, while any additional well-formed workflow +path — including a re-added `codeql-pr.yml` — is reported as +`unexpected workflow present in required set` drift instead of silently +passing. A malformed workflow entry (not an object, or missing a string +`path`) is now reported by its index (`central required workflow entry N is +malformed`) instead of being silently skipped, so a structurally broken +ruleset payload surfaces as loud audit failures rather than a quietly +incomplete inventory check. + +`tests/test_central_required_workflow_exact_inventory.py` pins the full +nine-path oracle independently of the production tuple, proves the +independent payload passes, and proves an extra live workflow fails. This +prevents a future edit to `REQUIRED_WORKFLOW_PATHS` from silently rewriting +the only happy-path fixture. The scheduled audit and rollout-document tests +continue to assert the canonical code-scanning paths explicitly. ## Scheduler required workflow posture @@ -156,9 +212,7 @@ The central `.github/workflows/pr-review-merge-scheduler.yml` is now part of the Do not centralize the scheduler by running a `.github` scheduled job against other repositories with the `.github` repository token. That would either fail permission checks or use the wrong mutation actor. The central path is a required workflow executed in each target repository context. -- Heartbeat fallback posture: event-driven target-repository runs stop retrying once their triggering event is consumed, so a PR that becomes mergeable AFTER its last event (approval published after the scheduler pass, merge-preview checks landing late, a temporary base-branch policy blocker clearing) has no later trigger and sits approved-but-unmerged. The `org-queue-sweep` job in the central scheduler workflow closes this gap: it runs hourly (`0 * * * *`) only in `ContextualWisdomLab/.github`, re-runs the same trusted scheduler script against every non-archived organization repository, and merges/updates through the identical guarded contract. Stacked PRs, which do not receive injected required workflows, use a separate bounded OpenCode dispatch budget so ordinary default-branch traffic cannot leave them at `OpenCode review absent`. It never uses the `.github` repository `github.token` for sibling mutations — it requires `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, or the exchanged OpenCode app token, and fails with a visible `::error` reason when no cross-repository mutation credential is available instead of silently no-opping. Every swept repository prints its per-PR decision log, so an unmerged PR always has a concrete logged reason at most one hour old. -- Queue hygiene posture: during the sweep, workflow runs still `queued` after `ORG_SWEEP_STALE_QUEUE_HOURS` (default 24h) are cancelled with their run id, workflow name, head branch, and age logged. A run queued that long belongs to a head that PR events will never revisit (closed PR, force-pushed branch, or a previous runner outage), and leaving it keeps the Actions queue holding non-current-head work. -- Inaccessible-repository posture: a sibling repository the sweep credential structurally cannot read — the OpenCode app is not installed there, or `PR_REVIEW_MERGE_TOKEN` does not cover it — returns HTTP 403 `Resource not accessible by integration` on every read. That is an access-grant fact the automation can never resolve, so the sweep classifies it as a skipped, non-fatal **unavailable** repository (a `::warning` naming the repository and the remediation) instead of a hard failure. Without this, a handful of un-enrolled repositories keeps the scheduled sweep heartbeat (the org sweep's `0 * * * *` cron) permanently red and masks a genuinely new repository that starts failing. Fail-closed is preserved on both sides: any non-403 scheduler failure still fails the sweep with its per-PR reason, and if more than `ORG_SWEEP_MAX_UNAVAILABLE` (default 5) repositories become unreachable in one pass — a credential-scope regression rather than a few un-enrolled repos — the job fails loudly. Remediation for a listed repository is to install the OpenCode app on it or grant `PR_REVIEW_MERGE_TOKEN` access. +- Recovery posture: native PR and review events own normal progress, GitHub auto-merge owns required-check completion, and each repository keeps one daily `scan-pr-queue` recovery. The central organization-wide polling job was removed because each invocation occupied a runner, walked every repository, and amplified the same Actions and API pressure it was intended to repair. Same-PR supersession remains with trigger-aware concurrency and the repository-local exact-head coalescer. ## Second-reviewer (Noema) posture @@ -172,7 +226,7 @@ App has read-only Actions/checks/contents/status/code-scanning/Dependabot access and write access only to pull-request reviews. The PydanticAI `ReviewAgent` product in `ContextualWisdomLab/noema` -(`reviewer/noema_reviewer`, noema#9) is the target standalone judgement plane, +(`reviewer/noema_reviewer`, `ContextualWisdomLab/noema#9`) is the target standalone judgement plane, while the central Python gate remains the deployed fail-closed reviewer. The standalone package is not imported into the privileged workflow. External proof exists on `ContextualWisdomLab/clearfolio#161`: `cwl-noema-review[bot]` submitted @@ -197,16 +251,22 @@ SARIF/dependency evidence, test evidence, and review marker all bind to ## Scope -The active ruleset no longer maintains a repository-name allowlist. Live -ruleset inspection on 2026-07-02 18:15 KST reports -`repository_name.include=["~ALL"]`, so all current and future organization -repositories inherit the seven central required workflows on their default -branch unless a later ruleset exclusion is added. The table below is the public +The active ruleset uses `repository_name.include=["~ALL"]` together with the +canonical exclusions `.github`, `noema`, and `IRT-bibliography-set`, matching +`scripts/ci/audit_central_required_workflows.py::EXPECTED_EXCLUSIONS` and the +live ruleset contract re-verified on 2026-09-03 KST. Every current or future +organization repository outside that exclusion set inherits the nine central +required workflows on its default branch — the workflow count itself is not +fixed at the count an earlier inspection observed (seven, on 2026-07-02) or +at ten (2026-09-02, before `codeql-pr.yml`'s removal); see the "Active +required workflow paths" list under Decision above for the current live +count and treat that list, not this sentence, as the source of truth for how +many workflows are currently required. The table below is the public non-fork inventory snapshot and rollout ledger, not the ruleset target list. | Repository | Visibility | Default branch | Flow | Open PRs | Local central-workflow copies on default branch | Rollout status | | --- | --- | --- | --- | ---: | --- | --- | -| `ContextualWisdomLab/.github` | public | `main` | GitHub Flow | 27 | central source; keep | single source of truth; central PRs through `#283` merged; PR `#286` current head queued after review-thread fixes | +| `ContextualWisdomLab/.github` | public | `main` | GitHub Flow | 27 | central source; keep | single source of truth; historical central PRs are evidence only; current PR state must be re-read before action | | `ContextualWisdomLab/aFIPC` | public | `master` | GitHub Flow | 22 | none | central checks proven on PR `#78`; active queue still needs per-PR review | | `ContextualWisdomLab/pg-erd-cloud` | public | `main` | GitHub Flow | 81 | none | repo-local autofix worker removed by PR `#393`; default branch now keeps only repository-owned application and security workflows | | `ContextualWisdomLab/fast-mlsirm` | public | `main` | GitHub Flow | 25 | none | migrated; re-verify inherited checks on current open PRs | @@ -238,6 +298,34 @@ non-fork inventory snapshot and rollout ledger, not the ruleset target list. ## Evidence from this rollout +- On 2026-09-02 KST, live verification via `gh api repos///rules/branches/` + against six repositories (`aFIPC`, `bandscope`, `newsdom-api`, `naruon`, + `xtrmLLMBatchPython`, `pg-erd-cloud`) found ruleset `18156473`'s `workflows` + rule listed exactly the same seven required paths for every repository + checked, and that `codeql-pr.yml`, `scorecard-pr.yml`, and `osv-scanner-pr.yml` + were absent from all of them. This is historical pre-fix evidence, not the + current operator state. The gap required org-admin action and was fixed later + the same day. +- On 2026-09-02 KST, later the same day, an organization administrator + granted a session `admin:org` scope specifically to close the gap above. + With that scope, `gh api orgs/ContextualWisdomLab/rulesets/18156473` + confirmed the same seven-path gap from the org side, and + `PUT /orgs/ContextualWisdomLab/rulesets/18156473` appended + `.github/workflows/codeql-pr.yml`, `.github/workflows/scorecard-pr.yml`, and + `.github/workflows/osv-scanner-pr.yml` (each pinned to + `ContextualWisdomLab/.github@refs/heads/main`) to the ruleset's `workflows` + rule, preserving every other existing path and rule field unchanged. The + write was verified live from two independent angles: re-reading the org + ruleset itself, and re-reading `aFIPC`'s inherited dispatch list + (`gh api repos/ContextualWisdomLab/aFIPC/rules/branches/master`) — both now + show all ten required workflow paths. Interim restoration PRs + `ContextualWisdomLab/aFIPC#321`, `ContextualWisdomLab/bandscope#1144`, and + `ContextualWisdomLab/pg-erd-cloud#1059` may be retired only after verified + complete successor carryover of every unique valid delta; redundancy alone + is not a close instruction. +- On 2026-09-03 13:05 KST, the 23-repository CodeQL coverage gap recorded below was made permanently self-detecting instead of relying on another one-time manual sweep: `scripts/ci/audit_org_codeql_coverage.py` (pure `audit_codeql_coverage(repositories) -> list[str]` function plus a `load_payload`/`parse_args`/`main` CLI wrapper, 100% test and docstring coverage) flags any non-archived organization repository where both `code-scanning/default-setup` state is not `configured` and `code-scanning/analyses?tool_name=CodeQL` shows no recent run, exactly the two signals used to find the original 23 repositories; archived repositories are skipped, matching the `trivy-sarif-repro` exclusion below. The existing scheduled `audit-central-ruleset.yml` workflow (cron `11 2 * * *`, plus `repository_dispatch` and relevant-path `push`) now also enumerates every organization repository via `gh api --paginate "orgs/${ORG_LOGIN}/repos?type=all&per_page=100"`, probes both coverage signals per repository (tolerating a 404/403 on either endpoint as no-coverage rather than a hard failure), and pipes the result into this script. Like the existing ruleset audit, this is read-only: it reports drift with `ERROR:`/`FAIL:` lines and a nonzero exit code, and never mutates default-setup or repository settings itself — a newly created repository or one where default-setup is later disabled will now surface here on the next scheduled run instead of silently regressing. +- On 2026-09-04 KST, backlog item 38 closed the remaining remediation gap. The same daily audit now exchanges its trusted-main OIDC identity for an OpenCode GitHub App installation token and runs `scripts/ci/bootstrap_codeql_pull_requests.py` before the final fail-closed audit. Each uncovered, non-archived repository receives at most one `opencode/codeql-setup` pull request against its exact default-branch SHA. The generated workflow queries GitHub's language statistics on every default-branch push and scheduled run, maps every [CodeQL-supported language](https://docs.github.com/en/code-security/reference/code-scanning/workflow-configuration-options#languages-to-be-analyzed) to its canonical identifier, always includes Actions analysis, and uses `build-mode: none`; it therefore adapts when the repository stack changes without executing repository build scripts or PR heads. Organization-required `codeql-pr.yml` remains the single PR scanner, avoiding duplicate local PR jobs. Existing open setup PRs are reused, an unexplained bot branch blocks rather than being overwritten, empty repositories wait for their first commit, and every action is pinned to a full commit SHA. The bootstrap treats the installation token as an opaque non-empty value and uses a multiline output, so neither the older fixed-length token nor GitHub's [new stateless installation-token format](https://github.blog/changelog/2026-05-15-github-app-installation-tokens-per-request-override-header/) is assumed. The trusted central workflow alone performs writes; it never checks out or executes a target repository's PR head. +- On 2026-09-03 12:20 KST, ruleset `18156473` was updated to remove `.github/workflows/codeql-pr.yml` from its required `workflows` list, bringing the count to nine. Every ruleset-injected run of that workflow, in every one of the ~71 covered repositories, had concluded `startup_failure` with zero check runs ever created — the REST API surfaces no reason, but the run page's web UI "Annotations" panel does: `github/codeql-action/init` and `github/codeql-action/analyze` are categorically disallowed inside a required workflow, a GitHub platform restriction confirmed by independent web corroboration, not a defect in the workflow file's own content. Before treating removal as safe, real CodeQL coverage was ground-truth-verified (via `code-scanning/analyses`, not workflow-file-name pattern matching — some repositories run CodeQL from unexpectedly-named files, e.g. `contextual-orchestrator`'s coverage comes from `security.yml:codeql_analysis`) across all 71 covered repositories: 48 already had real coverage from a local workflow or GitHub's native default-setup; 23 (`CalendarWeave`, `ConceptWeave`, `DiagramWeave`, `ELUNVERA`, `EmbedRelay`, `LineageWeave`, `Orgmetra`, `OriginWeave`, `PolicyWeave`, `TEPP`, `accounting-information-platform`, `context-graph-contracts`, `disksage`, `enterprise-architecture-core`, `j-planner`, `learning-content-studio`, `learning-interoperability-contracts`, `learning-management-platform`, `learning-record-store`, `life-os`, `pingora-gateway`, `quarantine-sandbox-runtime`, `supply-chain-control-plane`) had none from any source and were given GitHub's native `code-scanning/default-setup` (`trivy-sarif-repro` excluded — an archived, explicitly-throwaway repro repository, not a real coverage gap). `.github#1768` records this in `docs/product-technical-gap-baseline.md`. - On 2026-08-28 21:43 KST, ruleset `21732164` was created with active enforcement for every non-default branch. Reproduction on an existing LineageWeave PR head and a new branch returned GH013 before either ref could emit the required workflow event. The ruleset was returned to `evaluate` mode at 21:49 KST; the audit now fails if this impossible all-ref contract is reactivated. - On 2026-06-30 08:33 KST, organization ruleset `18156473` was changed from an explicit repository-name list to `repository_name.include=["~ALL"]` while keeping `ref_name.include=["~DEFAULT_BRANCH"]` and the same three central required workflow paths from `.github@refs/heads/main`. @@ -245,10 +333,10 @@ non-fork inventory snapshot and rollout ledger, not the ruleset target list. - On 2026-07-01 06:30 KST, organization ruleset `18156473` still reported `enforcement=active`, `repository_name.include=["~ALL"]`, `ref_name.include=["~DEFAULT_BRANCH"]`, and the three required workflow paths from `ContextualWisdomLab/.github@refs/heads/main`. - On 2026-07-02 07:25 KST, organization ruleset `18156473` still reported `enforcement=active`, `repository_name.include=["~ALL"]`, `ref_name.include=["~DEFAULT_BRANCH"]`, and the same three required workflow paths from `ContextualWisdomLab/.github@refs/heads/main`. - On 2026-07-11 11:30 KST, organization ruleset `18156473` was normalized to keep the five central required workflows, stale-review dismissal, last-pusher protection, and review-thread resolution while setting `required_approving_review_count=0` and `require_code_owner_review=false`. The merge gate remains current-head OpenCode approval plus required checks and scheduler evidence; the change removes self-authored/code-owner deadlocks that left approved PRs unable to merge. -- On 2026-07-13 21:10 KST, live inspection found that `sast-semgrep.yml` described itself as the central replacement for removed repository-local Semgrep jobs but was absent from ruleset `18156473`. The active ruleset was updated to require that workflow from `.github@refs/heads/main`, while preserving one approval, stale-review dismissal, last-push approval, and review-thread resolution. `scripts/ci/audit_central_required_workflows.py` and the scheduled ruleset audit now report each missing workflow, wrong source ref, or weakened review protection explicitly. +- On 2026-07-13 21:10 KST, live inspection found that `sast-semgrep.yml` described itself as the central replacement for removed repository-local Semgrep jobs but was absent from ruleset `18156473`. The active ruleset was updated to require that workflow from `.github@refs/heads/main`, while preserving one approval, stale-review dismissal, last-push approval, and review-thread resolution. `scripts/ci/audit_central_required_workflows.py` and the scheduled ruleset audit now report each missing workflow, wrong source ref, weakened review protection, malformed/duplicate entry, or unexpected workflow explicitly. - On 2026-07-13 22:21 KST, the first main-branch ruleset audit proved that a repository `GITHUB_TOKEN` cannot read the organization-administration endpoint (`HTTP 403 Resource not accessible by integration`). The audit uses the least-privilege inherited-ruleset endpoint, logs `RULESET_SCOPE` for each enumerated repository, and validates the complete workflow and pull-request rule payload through `naruon`. The original public-only scope and its historical `.github`/`argos`/`noema` exclusions were superseded by the 2026-07-23 audit below. - On 2026-07-13 22:37 KST, xtrmLLMBatchPython current-head evidence proved that Semgrep 1.169.0 reports zero blocking findings while retaining 23 source-suppressed results in raw SARIF. The central gate now logs the suppressed count, removes only SARIF results carrying explicit in-source suppressions before upload, and fails from the remaining SARIF finding count even when Semgrep's SARIF-mode exit code is zero. -- On 2026-07-16 14:18 KST, `ContextualWisdomLab/clearfolio#161` proved the independent reviewer on exact current head `4512fb9e9b56ab95df3acd85ebec2e6b849335a7`: `cwl-noema-review[bot]` submitted an App-authored `APPROVED` review whose body records the same Head SHA and cites the clean SARIF, dependency, test, and diff evidence. +- On 2026-07-16 14:18 KST, `ContextualWisdomLab/clearfolio#161` proved the independent reviewer on exact current head `4512fb9e9b56ab95df3acd85ebec2e6b849335a7`: `cwl-noema-review[bot]` submitted an `APPROVED` review whose body records the same Head SHA and cites the clean SARIF, dependency, test, and diff evidence. - On 2026-07-23 06:35 KST, ruleset `18156473` was updated to require `.github/workflows/noema-review.yml`, making seven central required workflows while preserving exactly two approvals, stale-review dismissal, last-push approval, review-thread resolution, and merge/squash-only policy. The all-repository scope excludes only `.github`, `noema`, and private `IRT-bibliography-set`; `argos` now inherits the ruleset. The scheduled audit now enumerates every organization repository visible to its credential (`type=all`), rather than only public repositories, so the private exclusion and all other visible private-repository inheritance are verified. Existing open PRs may need a new PR event or branch update before GitHub creates the newly required Noema run. - `.github` PR `#225` raised high reasoning effort for all reasoning-capable OpenCode review model definitions and merged at `50c6ef82f52af3eeb0e58c174902fc9855c36682`. - `.github` PR `#226` stopped the merge scheduler from treating old deterministic fallback approval bodies as current-head approval evidence and merged at `57a1fa580731a0f76b31dcf29a597c5715dba2fd`. @@ -267,10 +355,7 @@ non-fork inventory snapshot and rollout ledger, not the ruleset target list. - `.github` PR `#283` refreshed the central OpenCode model configuration so every reasoning-capable review candidate sets `reasoning=true`, `options.reasoningEffort: high`, and `variants.high.reasoningEffort: high`; non-reasoning fallback candidates remain available without a false effort claim. It merged at `ef9950e6b55bf943c0295e1df3e34c94210d21cc`. - After PR `#255` merged, `ContextualWisdomLab/bandscope` PRs `#493`, `#494`, `#495`, and `#500` were rechecked for branch freshness. Merge simulation against `develop` found real conflicts rather than update-branch candidates: `#493` conflicts in `apps/desktop/src/App.tsx` plus the design-system docs, while `#494`, `#495`, and `#500` conflict in `docs/design-system/README.md`, `docs/design-system/component-contract.md`, and `docs/design-system/figma-to-code-workflow.md`. Each PR received a corrected conflict-resolution comment with the exact file list and merge/rebase repair commands. - `ContextualWisdomLab/aFIPC` PR `#78` is no longer a target-coverage gap. It merged after current-head central `coverage-evidence`, `opencode-review`, `strix`, and `scan-pr-queue` checks all passed on head `b1ddafced86302f461e95259699f1efde5ec87c9`; the OpenCode review approved the same head on 2026-06-30 06:02:55Z. -- `ContextualWisdomLab/pg-erd-cloud` PR `#393` removed the repo-local `pr-review-autofix.yml` worker after the central autofix worker merged. - The first OpenCode run on head `9d8eed5be47670b1b46f413295d9a6044d7327b2` exhausted the older model pool and requested changes. - After `.github` PR `#246` merged, central OpenCode run `28485070313` approved the same head and the PR merged at `1e0d6a3dda5ea9afcd74dcd8380689672e1c8ef1` on 2026-07-01 00:33:50Z. - Live default-branch content lookup returned 404 for `.github/workflows/pr-review-autofix.yml` after merge. +- `ContextualWisdomLab/pg-erd-cloud#393` removed the repo-local `pr-review-autofix.yml` worker after the central autofix worker merged. The first OpenCode run on head `9d8eed5be47670b1b46f413295d9a6044d7327b2` exhausted the older model pool and requested changes. After `.github` PR `#246` merged, central OpenCode run `28485070313` approved the same head and the PR merged at `1e0d6a3dda5ea9afcd74dcd8380689672e1c8ef1` on 2026-07-01 00:33:50Z. Live default-branch content lookup returned 404 for `.github/workflows/pr-review-autofix.yml` after merge. - Live non-fork inventory on 2026-07-02 18:15 KST found 17 public non-fork repositories, inherited ruleset `18156473` on `kaefa` and `waf-ids-ai-soc`, and no default-branch copies of `opencode-review.yml`, `strix.yml`, or `pr-review-merge-scheduler.yml` outside `.github`. - `ContextualWisdomLab/waf-ids-ai-soc` PR `#6` merged at `e1c0a85fd4a8e6dd67039be43eb7f659fec22abd` after central required workflow proof on head `43b62b5f347d1532c81b5ae38d8e41b4494fd486`; PR `#8` current head `48d8b56a0f995829fc95de4fed129d1c33aaadff` is now the open runtime proof fixture with central and local Rust checks queued at the 2026-07-02 18:15 KST refresh. - `ContextualWisdomLab/kaefa` inherits ruleset `18156473`, but PR `#60` current head `13c9089855fcdd34391173560ccf6935bac1eebe` showed only repo-local R-CMD-check, dependency-review, and CodeQL signals in status rollup. Treat this as a runtime proof gap until a new PR event or manual dispatch proves central OpenCode, Strix, and scheduler checks on a kaefa current head. @@ -312,18 +397,19 @@ non-fork inventory snapshot and rollout ledger, not the ruleset target list. - `ContextualWisdomLab/pg-erd-cloud` PR `#361` removed the repo-local `pr-review-fix-scheduler.yml` wrapper after central `.github` gained target repository support. It merged at 2026-06-29 22:40 KST with merge commit `21cbc14b21d59ac28ac789de58502816cc8df6ad`; live default-branch content lookup returned 404 for that wrapper path after merge. - `ContextualWisdomLab/naruon` classic branch protection no longer requires direct `strix` or `opencode-review` status checks on `develop`; after deletion, `branches/develop/protection/required_status_checks` returns `404 Required status checks not enabled`, while org ruleset `18156473` remains `active` and still targets `naruon`. - `ContextualWisdomLab/naruon` PR `#852` rewrites `backend/tests/test_release_governance.py` and `docs/development/merge-gate-policy.md` to make the central scheduler the contract, then deletes the repo-local `pr-review-merge-scheduler.yml`. The first current-head central `coverage-evidence` failed because nested `backend/requirements.txt` was not installed; `.github` PR `#146` fixed that central path. PR `#852` was pushed to head `2c8257ce0d02838b80650997d65e85569f4ab27f` to generate fresh required workflows from the updated central main. The stale OpenCode `CHANGES_REQUESTED` review `4592643416` on previous head `0f103836f15d9055c4ed85152f925a6e9514adb2` was dismissed on 2026-06-30 00:25 KST; the PR now requires fresh current-head OpenCode/coverage evidence and still has queued `coverage-evidence`. +- 2026-09-03 KST runner-admission repair (queue-congestion investigation: 9,368 checks queued organization-wide, roughly 3 in-progress, queue depth roughly equal to open-PR-count times required-workflow-count): live re-verification confirmed ruleset `18156473` (fetched via `gh api orgs/ContextualWisdomLab/rulesets/18156473`) covers exactly the same 10 workflows with `repository_name.exclude=["noema",".github","IRT-bibliography-set"]`, `.github`'s classic protection (fetched via `gh api repos/ContextualWisdomLab/.github/branches/main/protection`) requires exactly the same 14 named contexts with `strict: true`/`enforce_admins: false`, and `bandscope`'s live workflow directory has no local `codeql-pr.yml`/`strix.yml`/`security-scan.yml` while ruleset-injected runs of all three exist there -- proving a trigger-level `paths`/`paths-ignore` filter on a required workflow is inert in 40+ repositories and would leave `.github`'s classic contexts Pending forever. **Decision: trigger-level path filtering on a required workflow is a no-go; job-level `if:` gating is the safe mechanism.** A `changed-scope` job (byte-identical apart from one `if:` line) was added as the first job in `security-scan.yml`, `sast-semgrep.yml`, `strix.yml`, `scorecard-pr.yml`, and `osv-scanner-pr.yml`; downstream jobs gained `needs: changed-scope` plus an output-gated `if:`. `codeql-pr.yml`'s `detect-languages` job gained the same classifier as a step, but `analyze-head` is gated at STEP level (not job level) because run `33708209086` proved a job-level skip on a job whose matrix comes from another job's output publishes the unexpanded `${{ matrix.language }}` check-run name instead of the required `CodeQL compatibility analysis (actions|python)` contexts; `analyze-merge` (required nowhere) keeps a job-level guard. `strix.yml` keeps its existing `paths-ignore:` (the one documented exception -- verified via a live run-event census that its runs are native, not ruleset-injected, in the three excluded repositories) with corrected comments. `sbom-generation.yml` dropped its `pull_request` trigger for `push`+`release` only, since nothing gated on the PR-scoped SBOM artifact and its `dependency-snapshot: true` submission is the only feeder of the dependency graph `sbom-inventory-scheduler.yml` reads hourly -- a PR-head snapshot was polluting that graph. Every ruleset-injected `CodeQL PR` run observed in every covered repository (`bandscope`, `naruon`, `aFIPC`, `pg-erd-cloud`, `xtrmLLMBatchPython`) is `startup_failure` with zero check runs created; that is an independent, pre-existing, higher-priority blocker this repair does not fix (see `docs/doctoring/required-workflow-path-filter-boundary.md`, which also has the full live evidence and the doc/image pattern-list fix that replaced `LICENSE.*` -- it matches the executable `LICENSE.py` -- with explicit `LICENSE`/`LICENSE.txt`/`COPYING`/`COPYING.txt`/`NOTICE`/`NOTICE.txt` names). `tests/test_docs_only_pr_runner_admission.py` is the RED-first contract. ## Good patterns to keep - `naruon`: separates PR Governance, OpenCode review, Strix evidence, and application CI into explicit checks. - `.github`: centralizes reusable workflow logic and review/merge scheduler code. -- `pg-erd-cloud`: its previous repo-local autofix worker was folded into the central `PR Review Autofix` worker and removed from the repository by PR `#393`; keep only repository-specific application and security checks locally. +- `pg-erd-cloud`: its previous repo-local autofix worker was folded into the central `PR Review Autofix` worker and removed from the repository by `ContextualWisdomLab/pg-erd-cloud#393`; keep only repository-specific application and security checks locally. - `ContextualWisdomLab.github.io`: thin caller pattern is acceptable for repository-local workflows only when GitHub does not offer an organization-level control. It should not be the default rollout mechanism. ## Risks and follow-up - Existing open PRs may need a new push or base update before the latest required workflow SHA appears on their current head. -- The central OpenCode workflow now retries DeepSeek R1, DeepSeek V3, GPT-5, and a catalog fallback pool. Keep model/tooling failures out of PR comments unless there is a source-backed failed-check diagnosis. +- The central OpenCode workflow now routes model-backed review through the canonical contextual-orchestrator contract; model/provider selection and fallback belong to that owner boundary, not workflow-local heuristics or paid fallback. - The central OpenCode config includes a read-only `code-reviewer` subagent for focused review passes. The subagent may read, grep, glob, and run safe local verification commands, but it must not edit files, stage changes, commit, push, install dependencies, mutate branches, or touch production state. - OpenCode execution evidence must be sandboxed in the CI workspace or an isolated temporary directory, with a credential-scrubbed environment by default and no persistent mutation outside test caches or scratch files. Prefer `python3 scripts/ci/sandboxed_verify.py --repo-root -- ` when the central helper is available, and cite its `SANDBOXED_VERIFY_RESULT` line. When repo-native verification legitimately needs network access or GitHub Secrets, pass only the needed names with `--allow-env`, record `--network required`, and explain it with `--evidence-note` without printing secret values. The helper does not replace existing bash, task, webfetch, websearch, lsp, CodeGraph, DeepWiki, Context7, or web_search review policy. If a verification cannot be sandboxed without changing the result, the review must say so instead of presenting an unsafe run as evidence. - Web application reviews should run backend, frontend, and repository-native E2E checks together through `python3 scripts/ci/sandboxed_web_e2e.py --repo-root --backend-cmd --frontend-cmd --e2e-cmd ` when those contracts exist, then cite `SANDBOXED_WEB_E2E_RESULT`. If backend/frontend/E2E/readiness contracts are missing, the review must name the gap instead of treating unit or lint evidence as full E2E proof. @@ -334,6 +420,6 @@ non-fork inventory snapshot and rollout ledger, not the ruleset target list. - Same-repository post-approval merge/update follow-up should use the workflow `github.token` first so the mechanical actor is `github-actions[bot]`; cross-repository manual dispatch may still fall back to configured secrets or the OpenCode app token when the workflow token cannot mutate the target repository. - Do not copy central Strix, OpenCode, merge scheduler, fix scheduler, or autofix worker workflows into repositories. Repository-local application CI and security CI may remain when they are not substitutes for the central workflows. - The central autofix worker is for source-actionable current-head review findings. It must not treat model-pool exhaustion, missing approval evidence, unresolved human threads, failed checks, `coverage-evidence`, Strix failures, `DIRTY`, or `CONFLICTING` merge states as code-autofix requests; those states need retry, failed-check explanation, branch update, or conflict guidance instead. -- `pg-erd-cloud` no longer has a repository-local `pr-review-autofix.yml` worker on its default branch. Live default-branch workflows after PR `#393` are `ci.yml`, `codeql-backfill.yml`, `codeql.yml`, `dependency-review.yml`, and `scorecard.yml`. +- `pg-erd-cloud` no longer has a repository-local `pr-review-autofix.yml` worker on its default branch. Live default-branch workflows after `ContextualWisdomLab/pg-erd-cloud#393` are `ci.yml`, `codeql-backfill.yml`, `codeql.yml`, `dependency-review.yml`, and `scorecard.yml`. - Some repositories use classic branch protection while others use rulesets. Normalize branch protection into rulesets without removing repository-specific required application checks. - Existing PRs may not show newly inherited required workflows until a new PR event or branch update occurs, even though the org ruleset now uses the all-repository condition. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 29acdfeecc..1cc9e20313 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1752,6 +1752,41 @@ string, a bare number) confirmed to fail against the pre-fix script (`KeyError: signature as the original round-4 bug) before passing after the fix. 1930 tests pass; 100% coverage and 100% docstring coverage on `scripts/ci/`. +## 2026-08-31 opencode.jsonc nvidia-nim block: follow-up to the 2026-08-30 ZDR/NIM-routing review + +**Supersedes, for this one item only, the 2026-08-30 "ZDR/NIM-routing architecture review" entry's call +to leave `opencode.jsonc`'s dormant `nvidia-nim` provider block in place** (that entry's other findings — +`select_nvidia_nim_model.py` already removed by `#1442`, `run_opencode_review_model_pool.sh`'s dead +NIM-candidate branches, Strix's `orchestrator/free`-only narrowing — are unaffected and not revisited +here). Per this repo's "append a dated note, don't rewrite history" convention, that entry is left +unedited; this is the follow-up. + +Two independent investigation passes re-examined the same block this pass and found the 2026-08-30 +entry's stated justification ("may still serve local/interactive OpenCode use outside CI") does not +survive a check of `enabled_providers`: `opencode.jsonc:9` lists only `["contextual-orchestrator"]`, so +the block confers zero benefit even for a developer running `opencode` locally from repo root — they +would need to hand-edit `enabled_providers` regardless of whether the block exists, at which point a +gitignored local override serves the same purpose without stale in-repo scaffolding and an +undocumented-outside-a-stale-hotfix-doc `{env:NVIDIA_API_KEY}` credential alias. More importantly, two +assertions in `scripts/ci/test_strix_quick_gate.sh` (`opencode config enables nvidia-nim provider` / +`opencode config points nvidia-nim at NIM API`) were pinning the block's *presence* as if it were still +required — accurate when authored for the pre-`#1364` design, stale and misleading since. Removed the +block, fixed the two assertions to `assert_file_not_contains` (matching the sibling assertions already +forbidding the old NVIDIA NIM model-id defaults), and deleted `docs/nvidia-nim-opencode-hotfix.md` per +its own Rollback section. Full trace, safety argument, and the separate `strix_quick_gate.sh` +allowlist/`zdr_policy.py` audit (both confirmed non-bypass, left untouched) are in +`docs/doctoring/opencode-jsonc-nvidia-nim-block-removal.md`. Net effect: no runtime behavior changes +(the block was already unreachable in every automated review path); the contract-test suite now asserts +the actual, current state instead of a retired one. + +Left for a separate follow-up, not attempted this pass (matching this org's stated preference for +splitting unrelated dead-code cleanups into their own PRs, per the `#1437` review-thread precedent): +`scripts/ci/run_opencode_review_model_pool.sh`'s dead `nvidia-nim/*` candidate-handling branches and +their dedicated tests, and `docs/doctoring/hourly-nvidia-nim-autofix.md`'s stale "Provider contract" +section (still describes the scheduled autofix worker as calling `integrate.api.nvidia.com` directly +with a hard-coded model id — the exact pre-ADR-0003 pattern `test_pr_review_autofix_nvidia_nim_contract.py` +already forbids in the live workflow; the doctoring record itself was never updated to match). + ## 2026-08-31 noema-review-gate: malformed LLM JSON crashed the required check instead of failing closed The required `noema-review` check on `ContextualWisdomLab/contextual-orchestrator#960` crashed with an @@ -2613,3 +2648,708 @@ Higgins, S. S., Crepalde, N., & Fernandes, L. (2021). Segmented multiplexity: A **Expected effect.** No observable change to any current GitHub Actions review run (every current invocation already resolves to `free`). The effect is structural: it is no longer possible for a future workflow edit or manual dispatch override to admit priced-model spend into a required review check without an explicit, reviewed code change to this one `case` statement (and its now-locked-in regression test) first. **Follow-up.** If the organization later solves free+ZDR routing robustly enough to deliberately widen required-review CI to `orchestrator/auto` (e.g. once a spend ceiling and reviewer-visible cost evidence exist for that path), the change is exactly one `case` arm plus the corresponding assertions in `test_sidecar_pins_the_pool_to_free_for_github_actions` — this entry is the record of *why* it was narrowed, not a permanent prohibition. + +## 2026-09-02 org-queue-sweep investigation: historical conclusion superseded by PR #1821 + +**Current status (2026-09-04).** The conclusion below was invalidated by live queue evidence. PR #1821 removed the organization-wide Actions-run inventory and cancellation block from `org-queue-sweep` and merged as `11bb6a7871f4d95ab8a3eab616b4264d02327010`. Native per-PR concurrency and the current-head coalescer now own stale-run cancellation; the scheduled sweep retains only missed review, merge, and branch-update recovery. Focused ownership contracts passed 78 tests before merge. This preserves the event-gap recovery described below without paying the repository-wide run-listing and cancellation API cost. + +**Task.** A peer session flagged `org-queue-sweep` (`.github/workflows/pr-review-merge-scheduler.yml`) as a suspected contributor to the organization's shared GitHub API rate-limit pressure (this session independently hit the GraphQL secondary rate limit repeatedly the same day, corroborating the general symptom) and asked whether it can be replaced with GitHub Actions' own native scheduling/filter/condition primitives instead of its current custom bash implementation. + +**What the job actually does.** `org-queue-sweep` walks every organization repository once per hourly tick, exchanging an OIDC-derived OpenCode app token, then re-running the same trusted, guarded scheduler contract used for event-driven per-repository runs against each one — updating branches, dispatching reviews, or merging, bounded by explicit per-tick budgets (`ORG_SWEEP_REVIEW_DISPATCH_LIMIT`, `ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT`, `ORG_SWEEP_BRANCH_UPDATE_LIMIT`) and a rotation index so a fixed repository-list order does not starve later repositories (`ContextualWisdomLab/.github#1219`). It exists because GitHub Actions has no event that fires when a PR *becomes* mergeable without a corresponding webhook — a PR approved, or whose required checks land, after its own last triggering event (or whose base branch advances after approval, making it merge-blocked as "behind") sits in that state indefinitely with no later trigger; only a fixed heartbeat notices it. This job's sibling, `scan-pr-queue`, does the same thing scoped to `ContextualWisdomLab/.github`'s own queue (org-queue-sweep explicitly excludes `.github` itself from its target list via `select(.full_name != "ContextualWisdomLab/.github")`). + +**Already fixed twice, very recently, by the same lever.** Both crons were already lengthened for exactly this rate-limit/Actions-capacity reason: +- `org-queue-sweep`: 15 min → hourly (`docs/doctoring/actions-queue-saturation-hourly-sweep.md`, `#1630`, 2026-09-01), after an observed 822-run Actions backlog. +- `scan-pr-queue`: 30 min → hourly, offset 30 minutes from `org-queue-sweep`'s tick so the two heartbeats do not collide (`#1704`, merged 2026-09-02). + +Both changes explicitly documented, in the workflow file itself and in doctoring, *why* the job cannot simply be removed (see below) — this investigation re-checked whether that reasoning still holds, rather than assuming it does. + +**Alternatives considered and rejected.** + +1. *Replace the custom org-wide walk with a native `strategy: matrix` job, one shard per repository.* Rejected: this does not reduce the number of GitHub API calls (still one queue-inspection pass per repository per tick) — it only parallelizes them across up to ~74 concurrent runners. The gap-baseline entry immediately above this one documents an already-observed, already-fixed floating-runner-image starvation incident causing multi-hour queuing across the org's required review workflows. Requesting dozens of concurrent hosted runners for one job, every hour, would make that class of incident more likely, not less — this is a regression risk, not an improvement. +2. *Remove the schedule trigger entirely and rely only on event-driven wakes (`pull_request_target`, `pull_request_review`, `workflow_run`, `repository_dispatch`).* Rejected: GitHub Actions has no native event for "a PR's mergeability changed because time passed or the base branch advanced." At the time, `workflow_run` listened only for OpenCode and Strix, not every required check, which made the scheduled recovery more—not less—necessary. Removing the schedule would silently reintroduce PRs stuck "approved but unmerged" with no operator signal — the same failure class `#1630`'s own root-cause section describes. +3. *Rely on GitHub's built-in auto-merge instead of a polling sweep.* Partially relevant, not a full replacement: native auto-merge (if enabled per-PR) does retry a merge automatically once required checks pass, which would reduce reliance on the sweep for the "waiting on a check that just went green" case specifically. It does **not** cover the "base branch advanced, PR is now behind and requires an explicit branch update" case (this repository's governance model requires an explicit `UPDATE_BRANCH` action per `docs/pr-review-and-merge-procedure.md`, not a bare auto-merge-on-green), and does not run the guarded scheduler's own review-dispatch/stacked-PR logic. Adopting org-wide auto-merge as a *complement* to (not replacement for) the sweep is a legitimate future lever, but is a merge-policy decision affecting every sibling repository's branch protection settings — out of scope for this investigation and not something to change without the owner's explicit sign-off. +4. *Reduce `ORG_SWEEP_MAX_PRS` (then 1000) or the per-tick dispatch/update budgets to cut API calls per tick.* Rejected because lowering the coverage bound would reintroduce the BandScope queue-omission incident. The investigation understated the cost, however: active repositories also incurred GraphQL pagination and per-PR REST reads. PR #1821 removed the separate Actions-run inventory/cancellation cost instead of shrinking PR recovery coverage. + +**Historical conclusion, now superseded.** The cadence and mergeability-recovery reasoning remains valid, but it incorrectly treated run cancellation as inseparable from that recovery. PR #1821 separated those responsibilities and deleted the API-heavy portion while keeping the necessary scheduled recovery. + +**Residual / follow-up.** Continue measuring total job creation across central required workflows and product-local duplicates. The 2026-09-04 consolidation wave moved OSV, Scorecard, Gitleaks, review-repair, and commercial-readiness checks into existing owners; queued-run counts still require live observation rather than configuration-only claims. + +## Noema single-request model-control ownership — PR #1672 (2026-09-02) + +**Status:** Merged into protected `main` as `a28fc2f4e185df7847e2f2f5f6ec561d1e84805d`; fresh exact-head hosted evidence remains an operational acceptance item. + +**Root cause.** Noema duplicated contextual-orchestrator structured-output repair by making a second model request and wrapped that request in an unmeasured 900-second repository wall-clock deadline. This created a self-hosting admission failure: valid long inference could be terminated by a policy that the gateway already owns. + +**Context Map / responsibility boundary.** `.github` owns CI review orchestration, exact-revision evidence, deterministic verdict validation, and publication. `contextual-orchestrator` owns provider discovery, capability routing, `orchestrator/free`, structured-output repair/failover, and provider completion. No provider/model-specific fallback or caller wall-clock timeout crosses that boundary. + +**Action delivered.** The recursive caller repair and fixed deadline/signal machinery were removed. Noema now sends one structured-output request, keeps exact-head checks before and after model work, sanitizes serving-model telemetry, restores exact changed-line diagnostics, and retains bounded non-heuristic evidence cardinality with strict local JSON parsing. + +**900-second clarification.** The historical `NoemaRepairDeadlineExceeded` from the html4tree incident came from the retired caller repair path. The three literal `timeout --kill-after=20 900` invocations still present in `opencode-review-dispatch.yml` are separate containment limits for untrusted test-measurement commands; they are not model or Noema inference timeouts. Telemetry and runbooks must report the command class and phase separately. + +**Evidence / acceptance.** Permanent tests forbid retry/deadline/sampling symbols in the caller and prove one gateway request, one attempt annotation, control-character-safe telemetry, missing-value rejection, valid trailing-comma normalization, and exact changed-line guidance. Fresh exact-head repository checks and reviews remain the admission authority; predecessor-head evidence is not transferable. The remaining runtime work is to preserve distinct `request_too_large`, discovery, rate-limit, provider transport, malformed-output, stale-head, and sandbox-command-timeout categories in hosted logs. + +## 2026-09-02 `test_strix_quick_gate.sh` stale cron assertion left broken by the `#1630` cadence lengthening + +**Problem.** The required `exact-head-path-policy` check (which runs `bash +scripts/ci/test_strix_quick_gate.sh` against the exact PR head) was failing on +multiple, unrelated open PRs (observed directly on `.github#1476`, a PR whose own +diff never touches this script or the scheduler workflow) with: + +``` +FAIL: scheduler wakes frequently enough to clear auto-merge PRs that become stale +after their initial PR events (missing 'cron: "*/30 * * * *"') +``` + +**Root cause.** `#1630` (referenced in `docs/doctoring/actions-queue-saturation-hourly-sweep.md`) +deliberately lengthened `pr-review-merge-scheduler.yml`'s repository-local heartbeat +from a quarter-hourly `cron: "*/30 * * * *"` to an hourly `cron: "30 * * * *"` to +reduce Actions-capacity pressure during the sustained organization-wide queue +saturation this session repeatedly documented. The Python regression +`tests/test_actions_queue_saturation_scheduler_cadence.py` was correctly updated at +the time (it now asserts `'- cron: "30 * * * *"' in workflow` and explicitly +`'*/30 * * * *' not in workflow`) — but the parallel bash contract test, +`scripts/ci/test_strix_quick_gate.sh`, was not, and kept asserting the literal old +string. This is a genuine, reproducible defect on protected `main` itself, not a +symptom of any one PR being stale: I confirmed it by running the script directly +against an unmodified, freshly cloned `main` (commit `8c085835`) before making any +change, and it failed with the identical message. + +**Why this matters at organization scale.** `exact-head-path-policy` is a required +check for every PR touching Strix-quick-gate-covered paths, checked out against +each PR's own exact head but running this trusted base-branch script. Since the +assertion can never pass against the current, correctly-updated workflow file, this +was a standing, silent block on an unbounded number of unrelated PRs across the +whole `.github` PR queue until fixed at the root -- exactly the class of "root +cause outside any one PR's diff" issue this session's operating directive requires +be fixed at the canonical location rather than worked around per-PR. + +**Fix.** Updated the one stale assertion (`scripts/ci/test_strix_quick_gate.sh`) +from `'cron: "*/30 * * * *"'` to `'cron: "30 * * * *"'`, matching the workflow's +actual current value and the already-correct Python-side assertion. Also corrected +an adjacent stale human-readable description ("scheduler isolates the 15-minute +organization sweep from the separate 30-minute scheduled scan") to the current +hourly/hourly cadence -- both `org-queue-sweep` and this repository-local scan are +now hourly, so the old minute figures described a schedule that no longer exists. + +**Verification.** `bash scripts/ci/test_strix_quick_gate.sh` — confirmed FAIL on +unmodified `main` before the change, confirmed PASS after. Full suite: +`coverage run -m pytest tests -q` — all passed; `coverage report --fail-under=100` +— 100% on `scripts/ci/`; `interrogate` — 100%. This is a bash-string-only fix with +no Python production code touched, so the full-suite pass is a non-regression +check, not evidence the fix itself works — the direct before/after script run is +that evidence. + +**Risk of this fix itself.** Essentially none: a one-line literal-string update in +a test assertion, verified to both fail before and pass after against the exact +same unmodified `main` checkout. No workflow, script, or other test file changed. + +**Expected effect.** `exact-head-path-policy` stops failing organization-wide PRs +on this assertion once this fix reaches protected `main`; any PR whose branch has +already synced past this point (or syncs after) picks it up automatically. + +**Follow-up.** None identified — this closes the specific gap. If a future cadence +change lands again, the durable fix is process, not code: update every test that +asserts the literal cron string (currently exactly these two files) in the same PR +that changes the cron value, per this repo's own "contract tests pin workflows AND +prose" convention already stated in `CLAUDE.md`. + +## Item 4 fresh evidence: gateway 500 after a 649.5s "connecting" phase with `served_model=unknown` — 2026-09-03 + +**Status:** A live, current instance of item 4's still-open telemetry complaint, distinct from the already-resolved html4tree/900-second caller-repair-deadline case above (that mechanism was removed by PR #1672). Recorded here from a fresh, exact job log. Two distinct defects were found in the one error line below, both root-caused and both with a fix proposed but not yet merged: a caller-owned phase-mislabeling bug (this repository's own `scripts/ci/noema_review_gate.py`, see below) and a gateway-owned attribution gap (`contextual-orchestrator`'s `_invoke` failover loop, relayed to and fixed by the peer session with deep context in that repo, see below). + +**Evidence, pulled directly from the run.** `ContextualWisdomLab/fast-mlsirm#1518`, "Required Noema Review" run [`33646974279`](https://github.com/ContextualWisdomLab/fast-mlsirm/actions/runs/33646974279/job/100304078562), job `100304078562`, step "Prepare Noema model verdict," `head_sha` `b8e72773c34cd2f383bf44f492e52bf61736c680`. The sidecar's own **preflight** probe (`02:41:24Z`) reports rich per-route detail for the `orchestrator/free` pool — 12 candidates probed, 5 ready, 7 rejected, each with an explicit `agent_id`/`model`/`provider`/`error_type` (`TimeoutError` or `HTTPError` with an `http_status`). The **real** verdict call that follows (`two_phase.py`'s actual `chat/completions` request, started `02:41:29Z`) then produces zero log output for **10 minutes 54 seconds**, until: + +```text +##[error]Noema gateway transport failed: HTTPError: HTTP Error 500: Internal Server Error; caller attempts=1, duration=649.5s, phase=connecting, served_model=unknown +##[warning]Noema gateway attempt outcome=failed phase=connecting duration=649.5s served_model=unknown; caller attempts=1 (gateway owns repair/failover). +``` + +**Why this matters, precisely.** `phase=connecting` for 649.5 seconds against a `127.0.0.1:18080` sidecar (same runner, not a remote network hop) is not a plausible literal TCP-connect duration. + +**Correction (Devin Review on this PR): the phase-labeling defect is caller-owned, not gateway-owned.** The first draft of this entry attributed the mislabeling to `contextual-orchestrator`'s `provider_transport.py`. Read directly, `scripts/ci/noema_review_gate.py`'s `call_llm` — in **this** repository — sets `active_phase = "connecting"` immediately before `opener.open(request)` (`:1479`) and does not advance it to `"reading"` until *after* `opener.open()` returns (`:1483`). `urllib.request`'s `opener.open()` covers the entire request lifecycle up to receiving response headers — connect, send, and the full server-side processing wait — so any time the local gateway spends actually working on the request is reported as "connecting" by this caller's own telemetry, regardless of what the gateway itself does internally. This is this repository's own defect to fix (advance `active_phase` past a distinct "sending"/"awaiting response" step before blocking on `opener.open()`, or otherwise stop conflating connection setup with the full wait), not `contextual-orchestrator`'s. + +`served_model=unknown` on the one call that actually matters (the real verdict request, not the preflight) is a separate, still-gateway-owned gap: the exact remaining work this section's own prior paragraph already named ("Telemetry and runbooks must report the command class and phase separately") — the preflight moments earlier proves the sidecar *can* report per-route model/provider/error_type detail; the real call's failure path evidently does not carry that same attribution back to the caller, and the caller cannot recover an attribution the gateway never sent. + +**Update: the caller-owned phase-labeling defect has a proposed fix, not yet merged (Devin Review: verified `bebd7c7` is unreachable from `main` — it lives only on the still-open `ContextualWisdomLab/.github#1661`; `scripts/ci/noema_review_gate.py` on `main` still emits `active_phase = "connecting"` with no `requested_model`, confirmed by re-fetching the live file — an earlier draft of this record incorrectly marked the fix as landed).** A peer session, working from this record's evidence trail, root-caused it and opened `ContextualWisdomLab/.github#1661`: `bebd7c7` renames `active_phase`'s "connecting" label to `awaiting_response` (since `urllib`'s `opener.open()` is one blocking call spanning connect, send, *and* the full wait for the upstream response — there is no hook to time those phases separately with this API, so a loopback sidecar's near-instant connection setup means nearly the entire duration was actually upstream processing time, mislabeled as a connectivity stall) and adds `requested_model` (the gateway alias from `payload["model"]`, always known upfront) to both the success and failure telemetry lines. A new regression test confirms the renamed phase actually appears — and the old "connecting" does not — for the exact failure shape this incident hit (an `HTTPError` raised during `opener.open()`, before any response exists); confirmed failing against the pre-fix phase name before committing. Full suite (2,660 tests) passed as of that PR's branch. This does not fix the underlying 649-second provider stall itself — that remains a real, separate, unresolved question — and until `#1661` merges, `main` still logs the ambiguous "connecting" label. + +**Formerly open, gateway-owned — now fixed, PR open.** The missing model/provider attribution on the real-call failure path (`served_model=unknown` where preflight proves the sidecar can report this detail) is root-caused and fixed: `ContextualWisdomLab/contextual-orchestrator#1037` (branch `fix/invoke-failover-attempt-telemetry`, based on `main` @ `f4e5fc67`, open, not yet merged). Root cause: `TaskOrchestrator._invoke`'s failover loop (`contextual_orchestrator/orchestrator.py:7660-7893`) tracked only the single most recent candidate's failure (`last_upstream_error`/`last_provider_response_error`, overwritten on every new candidate), discarding every earlier candidate's `agent_id`/`model`/`provider_name`/failure reason the moment the loop moved on — so a fully-exhausted pool's raised exception could only ever describe the last agent tried, exactly matching the `served_model=unknown` symptom above. Fix: `ProviderUpstreamError.detail` now conditionally surfaces `attempts` (one record per candidate: `agent_id`/`model`/`provider`/`error_code`/`provider_status`/`retryable`/`retry_attempt`, reusing the existing `_record_tool_fallback` shape — never raw exception text) and `stop_reason`, populated at all 3 of `_invoke`'s existing "candidate exhausted" exit points; `server.py`'s error-message helper surfaces the count/reason; a second, compounding bug (the 413 `request_too_large` handler silently dropping `exc.detail` via a missing 4th `_send_error` argument) was fixed alongside it since it shares the same attribution-loss shape. RED-then-GREEN on 3 new tests, regression guards (`test_detail_and_transport_are_preserved_for_callers`, `test_invoke_preserves_final_classified_failure_across_candidates`, `test_all_agents_failing_raises_after_trying_every_candidate`) confirmed unmodified, full suite green. Zero line-range overlap with the concurrently-active PR #1032 (confirmed via diff comparison — #1032 touches `_orchestrated_provider_completion`'s schema-repair accounting; this touches `_invoke`'s failover loop, a different code path), branched from `main` directly rather than stacked. `.github`-side follow-up still needed once both #1661 and #1037 land: `scripts/ci/noema_review_gate.py`'s `call_llm` catches `urllib.error.HTTPError` without calling `exc.read()`, so it cannot see the response body CO now sends on failure, and `_extract_served_model` only reads a top-level `data.get("model")` while CO nests everything under `error.detail`/`error_detail` — the caller needs its own small patch to actually surface what the gateway now provides. + +**Confirmed landed and working in production — 2026-09-05.** The `.github`-side follow-up named above shipped: `ContextualWisdomLab/.github#1831` ("ground verdicts and classify gateway errors," merged 2026-09-04), with a same-day test/coverage hardening pass in `#1835` and a further refinement in `#1850`. `call_llm` now distinguishes `urllib.error.HTTPError` specifically, labels that case `active_phase = "response_error"` (replacing the misleading generic label a plain transport failure would get), and calls a new `_extract_http_error_telemetry(exc)` helper that actually reads and parses the gateway's error response body — closing the exact `exc.read()` gap this entry named. Live confirmation, found incidentally while handling an unrelated Autofix event on `ContextualWisdomLab/.github#1757`: a fresh gateway failure on that PR (job `101084475966`, 2026-09-04T20:45:17Z) logged `HTTPError: HTTP Error 502: Bad Gateway; caller attempts=1, duration=284.7s, phase=response_error, served_model=google/gemma-4-31b-it` — a real model name, not `unknown`. The underlying gateway instability itself (a 502 after 284.7s) remains a separate, still-open, still-recurring problem this entry does not resolve — but the telemetry gap that made every prior instance of it undiagnosable is now closed. + +## Item 41: CodeQL PR `startup_failure` blocking merges org-wide — dispatch-safe re-admission in progress + +**2026-09-04 correction.** The emergency ruleset removal below fixed the old +entrypoint, but became stale after `.github#1778` moved `github/codeql-action` +into the native `codeql-scan-dispatch.yml` handler. Seven current PR heads then +materialized every other central workflow but no `CodeQL PR` run because +ruleset `18156473` still omitted the now-safe entrypoint. Completion therefore +requires protected-main audit/recovery contracts, a live ruleset re-add that +preserves every unrelated field, and fresh exact-head runs that do not conclude +`startup_failure`; configuration text alone is not completion evidence. + +**Problem.** Every ruleset-injected `codeql-pr.yml` run in every repository covered by org ruleset `18156473` (confirmed: bandscope, naruon, aFIPC, pg-erd-cloud, xtrmLLMBatchPython, wardnet, spanning 2026-09-02T20:12:52Z through 2026-09-03T03:15:43Z) concluded `startup_failure` with **zero check runs created** — while every other required workflow in the same PRs at the same time enqueued normally. Example: [wardnet run 33710719228](https://github.com/ContextualWisdomLab/wardnet/actions/runs/33710719228). + +**Root cause.** Not a workflow-YAML defect, and not the job-output-derived `strategy.matrix` a prior hypothesis in this session pursued and disproved before shipping a wasted fix. GitHub categorically disallows `github/codeql-action/*` inside a ruleset-required workflow — confirmed via the run's own browser-rendered error annotation, which the REST API does not surface (`gh api .../jobs` returns an empty `jobs` array with no diagnostic text for this failure class; a real gap in what this org's tooling can see through the API alone, worth remembering the next time a `startup_failure` needs live diagnosis). + +**Fix, applied and independently verified.** `codeql-pr.yml` removed from ruleset `18156473`'s required-workflow list (9 entries remain: `close-empty-pr.yml` through `osv-scanner-pr.yml`; confirmed live via `gh api orgs/ContextualWisdomLab/rulesets/18156473`). GitHub's native code-scanning default setup enabled on all 23 ruleset-covered repositories that had zero real CodeQL coverage from any source — ground-truth checked via `code-scanning/default-setup` state and actual analyses, not by grepping for a workflow file name (some repos run CodeQL from oddly-named files, which a filename-only sweep would miss): CalendarWeave, ConceptWeave, DiagramWeave, ELUNVERA, EmbedRelay, LineageWeave, Orgmetra, OriginWeave, PolicyWeave, TEPP, accounting-information-platform, context-graph-contracts, disksage, enterprise-architecture-core, j-planner, 4 `learning-*` repos, life-os, pingora-gateway, quarantine-sandbox-runtime, supply-chain-control-plane. Independently spot-checked 3 of the 23 (ConceptWeave, pingora-gateway, quarantine-sandbox-runtime): all `state: "configured"`. `.github` itself is unaffected either way (excluded from ruleset `18156473`; its own native `codeql-pr.yml` runs were never in the failing population). + +**Devin Review caught the original write-up overclaimed "resolved," and a first correction attempt still +had the arithmetic wrong** (labeled a group of 7 repositories as 4, and folded two separate result buckets +into one total — caught again, corrected here with the counts double-checked against the raw sweep output +before writing them down). A full org-wide sweep (all 74 `ContextualWisdomLab` repositories, checked live +via `code-scanning/default-setup` state plus a per-repository `.github/workflows` listing to catch +repo-local CodeQL files the default-setup API can't see) found two separate buckets of repositories beyond +the original 23 (46 repos were already correctly `configured`; `46 + 24 + 4 = 74` checks out): **24 +repositories reported `not-configured`**, and **4 separate repositories 403'd** with "Code Security must be +enabled" (Advanced Security itself is off for those 4). Of the 24 `not-configured`: 1 is `.github` itself +(excluded from this sweep's remediation — it uses its own native, non-ruleset-injected `codeql-pr.yml`, +already separately verified as unaffected), **7** already had a working repo-local `codeql.yml` +(`keyverse`, `newsdom-api`, `bandscope` — already tracked in `docs/org-required-workflow-rollout.md`'s +inventory table — plus `OmniRoute`, `litellm-patched-proxy`, `mightyETL`, `pg-erd-cloud`, correctly not +needing default setup, which GitHub refuses to enable alongside a custom scanning workflow), leaving **16** +genuinely gapped (`1 + 7 + 16 = 24`). The 4 that 403'd are private repos where Advanced Security itself is +off (`IRT-bibliography-set`, `xtrm-lead-pi-outbound`, `ccube-jco-potential-customer`, `trivy-sarif-repro` — +the last is archived) — **left un-actioned here**, since turning on GHAS for a private repository is a +billing decision (per-active-committer cost), not a mechanical fix, and needs the user's own call rather +than being enabled unilaterally. The 16 genuinely gapped repositories (`kaefa`, `aFIPC`, +`linux-cluster-ops`, `argos`, `contextual-orchestrator`, `inkspan`, `g7`, `saju-caldav`, `9drive`, +`macos_utility_packs`, `graphify`, `four-pillars`, `mhtml-etl-gateway`, `psychometrics-commons`, +`metering-billing-platform`, `governance-risk-compliance`) had genuinely zero coverage of any kind — +including `contextual-orchestrator` itself, this ecosystem's central LLM gateway. Default setup enabled on +all 16 directly via `PATCH /repos/{owner}/{repo}/code-scanning/default-setup`, each with GitHub's own +API-reported supported-language list for that repo (the endpoint rejects `javascript`/`typescript`/`rust` +as discrete values — only the combined `javascript-typescript` is valid, and Rust has no default-setup +language support at all yet, so `contextual-orchestrator` and `psychometrics-commons` get every other +detected language covered but not their Rust code specifically, a real, separate, currently-unclosed gap +worth its own follow-up once/if CodeQL's default setup adds Rust). Verified each landed (`state: "configured"`) +and a real scan run was queued (`run_id` returned) for all 16. + +**Future repositories: Devin's concern is real, and this sweep does not close it.** Checked whether the +org's `default_for_new_repos: "all"` policy (configuration `17`, "GitHub recommended", confirmed live via +`gh api orgs/ContextualWisdomLab/code-security/configurations/defaults` — note the plain configuration-list +endpoint misleadingly shows `default_for_new_repos: null` for the same configuration; the dedicated +`/defaults` endpoint is the one that's actually authoritative) is the reason future repos would stay +covered. It is not reliable: of the 16 gapped repositories above, 4 are forks (`argos`, `g7`, `9drive`, +`graphify` — GitHub does not apply org default security configurations to forks, expected, not a bug) and 2 +predate the configuration entirely (`kaefa`, `aFIPC`, created 2017). But **11 are plain, non-fork +repositories created between 2026-05-09 and 2026-08-18** — `linux-cluster-ops`, `contextual-orchestrator`, +`keyverse`, `inkspan`, `saju-caldav`, `macos_utility_packs`, `four-pillars`, `mhtml-etl-gateway`, +`psychometrics-commons`, `metering-billing-platform`, `governance-risk-compliance` — every one of them well +after this configuration's own `updated_at` of 2025-03-04, and none of them ever received it. Only 3 +repositories org-wide (`noema`, `feelanet-adfs`, `pg-llm-batch`) actually show configuration `17` attached +via `orgs/{org}/code-security/configurations/17/repositories`, out of 74 total. This is the same +"silently-inactive required check" pattern this document has recorded before, now confirmed in a new +domain (org-level security-configuration application, not required-workflow ruleset activation): the +setting exists, looks fully configured, and simply does not fire for most new repositories. **Not fixed +here.** The two real options — a periodic reconciliation sweep that catches repos the org policy missed +(in direct tension with this backlog's own item 15, which asks to remove scheduled sweep workflows for +rate-limit reasons), or escalating the unreliable `default_for_new_repos` behavior to GitHub support — are a +product/operational decision this record surfaces rather than makes. + +**Cross-reference.** This is a fresh instance of the "silently-inactive required check" pattern this document has recorded before — a required check that looks fully configured but fails (or, in the earlier instances, silently never fires) under a narrower activation condition than the surrounding docs assumed. + +## Backlog item 13 (Strix/OpenCode/Noema stale-head cancellation) — own hypothesis refuted, but a real bug was found in the process — 2026-09-03 + +**Status:** Investigated with a 9-agent workflow (4 independent file audits + 1 direct-evidence pull against the item's own cited example + 4 adversarial re-verification passes) plus a 4-agent follow-up (2 investigate + 2 adversarial verify) triggered by Devin Review findings, per `docs/doctoring/item13-stale-head-cancellation-audit-20260903.md`. Item 13 asks that Strix/OpenCode Review/Noema reliably cancel a PR's previous-head run when a new push supersedes it, citing `ContextualWisdomLab/naruon#1528` (run `33581213829`) as evidence of a gap. + +**Implementation pending protected merge in #1878.** Live pushes to #1878 showed that most workflows retired the prior HEAD automatically, while Required Noema Review and Current Head Run Coalescer each left one prior-HEAD run queued because their effective admission groups did not supersede by stable repository-and-PR identity. #1878 moves Noema concurrency to workflow admission, removes the coalescer's HEAD component, and keeps exact live-HEAD revalidation inside each trusted job before mutation. The same PR removes `org-queue-sweep`; stale-head retirement therefore has one owner at workflow admission instead of depending on an organization-wide runner and repository walk. The older out-of-order-event concern remains bounded by the mandatory live-HEAD gate: a stale event may replace a queued attempt, but it cannot publish review or cancellation evidence after its event HEAD stops matching the live PR. + +**Protected-main follow-up.** #1878 merged at `1b65dbc35e7183722ad77894e2d80b39993be90d`. The current-head duplicate worker is subsequently integrated into `pr-review-merge-scheduler.yml`, removing the standalone coalescer workflow's extra runner admission while preserving the same exact PR/head/base revalidation. + +**The cited evidence shows a different, real problem instead: pure queue starvation, not a cancellation gap.** `ContextualWisdomLab/naruon#1528`'s full 17-run history (pulled live) shows every run sharing one unchanged head SHA — no multi-SHA race ever occurred. This corroborates `docs/doctoring/actions-plan-concurrency-ceiling-20260903.md`'s plan-level-ceiling finding with a concrete, individually-named example rather than aggregate counts — the fix is capacity (a plan decision or added runner capacity), not a workflow-config bug. + +**Correction (2026-09-04, evidence audit):** the specific "cited Strix run sat 23h22m queued before it even started running" claim above is wrong, disproven by direct re-verification. Both attempts of the cited Strix job (`33581213829`) show `created_at == started_at` — attempt 1 (2026-09-02T01:54:46Z→01:56:44Z, 2 min) and attempt 2 (2026-09-03T01:17:10Z→01:31:18Z, 14 min) both started **immediately** and were **cancelled mid-run**, not after a long queue wait. This pattern (prompt start, cancel during execution) is the opposite of queue starvation and is consistent with `strix.yml`'s own `cancel-superseded-pr-runs` mechanism (already documented above as working correctly) firing on this run — though the exact trigger for canceling a run against an unchanged head SHA was not further traced here. The paired OpenCode Review run for the same commit (`33581213805`) tells a different, worse story than "still queued 24+ hours later with no job started": its 5 sequential dependent jobs each queued for hours — `required-workflow-bootstrap` ~7h57m, `coverage-source-tree` ~9h40m, `coverage-evidence` ~13h1m, `opencode-review` ~12h13m — before `opencode-review` finally started 2026-09-03T20:46:49Z, ran for ~6 hours, and was itself cancelled 2026-09-04T02:47:05Z, roughly two full days after the original push. **Net effect on this entry's conclusion: unchanged, if anything understated.** The specific "23h22m" number attached to the wrong run doesn't survive scrutiny, but the underlying severe-queue-congestion finding this entry uses it to support is corroborated more strongly by the OpenCode Review run's real multi-stage delays than the original single figure conveyed. Found via a user-initiated adversarial evidence audit of 6 cited CI runs (5 of 6 confirmed accurate; this was the one exception). + +**Current status:** implementation exists on #1878 but is not complete until exact-head required checks, independent review, protected merge, and post-merge workflow evidence succeed. No fix was applied to the refuted `strix.yml` paths-ignore claim. A peer session's lead on `naruon`'s `pr-governance.yml` (six runs on PR #1528's one unchanged SHA) was investigated further by fetching and reading the workflow and its gate script in full: a `check_run`-triggered job-slot-waste claim was corrected (the job's own `if:` restricts that path to CodeRabbit checks only — GitHub Actions requests no runner for a skipped job), and a proposed same-head debounce fix was found to be unsafe rather than implemented — `scripts/ci/pr_governance_gate.sh` evaluates live required-check/review-thread/CodeRabbit state on every run, not a pure function of head SHA, so skipping re-evaluation whenever the SHA is unchanged would leave the gate reporting a stale blocker list after a check finishes or a review lands. See `docs/doctoring/item13-stale-head-cancellation-audit-20260903.md` for the full trace. + +## `codeql-pr.yml` required-workflow hard limit closed org-wide — 2026-09-03 + +**Superseded/extended by "Item 41" above (Devin Review: this and that entry recorded the same closure with +different scope and counts, a real duplication risk for future operational drift — consolidating here +rather than deleting either, since each has content the other lacks).** This entry is the original, +narrower finding (23 gapped repositories, ruleset fix, `ContextualWisdomLab/.github#1767`) from earlier the same day. "Item 41" +above is the same finding re-verified with a full 74-repository sweep (not the ~71-repository ruleset-only +scope this entry used) that found 16 *more* gapped repositories this entry's narrower sweep missed, +including `contextual-orchestrator`, plus the still-open future-repository gap this entry does not address. +**Treat "Item 41" above as the current, complete record; this entry's specific repository list and `#1767` +citation remain historically accurate for the narrower 23-repository fix, but "Status: Closed" below applies +only to that narrower scope, not to the fuller picture "Item 41" documents.** + +**Status:** Closed for its own 23-repository scope (superseded above). Ruleset fix live (admin:org); documented in `ContextualWisdomLab/.github#1767`; coverage gap independently closed same day. + +**Root cause.** Ruleset `18156473` ("CWL Central required workflows") dispatched `.github/workflows/codeql-pr.yml` into every one of the ~71 covered repositories as a required workflow. Every such dispatch concluded `startup_failure` with zero check runs created — a 100% failure rate, not intermittent. The REST API surfaces no reason; the web UI's run-page annotation does: `github/codeql-action/init` and `github/codeql-action/analyze` are categorically disallowed inside a required workflow (confirmed against GitHub's own stated rationale — CodeQL needs repository-level configuration that the cross-repo required-workflow dispatch context cannot provide). No edit to `codeql-pr.yml`'s own content (matrix shape, permissions, `if:` gating) can fix this; it is a platform constraint, not a configuration defect. Two sessions converged on this independently the same day via the browser UI (the API alone hides it); a third session's initial hypothesis (a job-output-derived `strategy.matrix` being incompatible with required-workflow check-run pre-registration) was investigated, found unrelated, and redirected before it produced a wrong fix. + +**Impact beyond the immediate blocker.** This was not "stuck pending" (which `do_not_enforce_on_create` would only excuse at PR-creation time) — it was a required check that always resolved to a real failure, blocking ordinary (non-admin-bypass) merges on every ruleset-covered repository, independent of and additional to the plan-concurrency-ceiling and Strix cross-PR starvation causes already on record in this document's queue-congestion entries. Effectively every merge landed on a ruleset-covered repository up to this point did so via admin bypass rather than a genuinely passing required-check set. + +**Action delivered.** `codeql-pr.yml` removed from ruleset `18156473`'s required `workflows` list (the other nine required workflows, and the ruleset's `pull_request`/`deletion`/`non_fast_forward` rules and `bypass_actors`, are unchanged). Before treating removal as safe, real CodeQL coverage was ground-truth-verified — via the `code-scanning/analyses` API, not workflow-file-name pattern matching, since some repositories run CodeQL from unexpectedly-named files (e.g. `contextual-orchestrator`'s coverage comes from `security.yml:codeql_analysis`) — across all 71 ruleset-covered repositories. 48 already had real coverage from a local workflow or GitHub's native default-setup. 23 had none from any source: `CalendarWeave`, `ConceptWeave`, `DiagramWeave`, `ELUNVERA`, `EmbedRelay`, `LineageWeave`, `Orgmetra`, `OriginWeave`, `PolicyWeave`, `TEPP`, `accounting-information-platform`, `context-graph-contracts`, `disksage`, `enterprise-architecture-core`, `j-planner`, `learning-content-studio`, `learning-interoperability-contracts`, `learning-management-platform`, `learning-record-store`, `life-os`, `pingora-gateway`, `quarantine-sandbox-runtime`, `supply-chain-control-plane`. GitHub's native `code-scanning/default-setup` was enabled on all 23 (`trivy-sarif-repro` excluded as an archived, explicitly-throwaway repro repository, not a real product gap) — a repository-native, GitHub-managed mechanism that does not route through the required-workflow dispatch path and so cannot hit the same restriction. + +**Context Map / responsibility boundary.** `.github` owns which checks are *required*, not how each repository's own CodeQL analysis is *produced* — that responsibility already varies per repository (local workflow vs. native default-setup) and this fix does not centralize it further. A future central-CodeQL redesign, if wanted, should follow the same thin-required-entrypoint-dispatches-to-a-`.github`-native-workflow pattern `strix.yml`/`opencode-review.yml` already use, per the accompanying doctoring note. + +**Evidence / acceptance.** Live-verified: ruleset `18156473`'s `workflows` rule no longer lists `codeql-pr.yml` (`gh api orgs/ContextualWisdomLab/rulesets/18156473`); all 23 repositories return `state: configured` (some still finishing their one-time setup run, queued behind ordinary Actions capacity, not a recurring cost). Full mechanism writeup: `docs/doctoring/codeql-pr-required-workflow-always-fails.md` (branch `claude/fix-codeql-required-workflow-restriction`, `ContextualWisdomLab/.github#1767`). Do not re-add any workflow using `github/codeql-action` to a required-workflows ruleset entry in this or any GitHub organization — the restriction is platform-level, not something this org's configuration can work around. + +## Item 23 (Noema review-gate failure retrospective) — 17 incidents re-aggregated into 5 root-cause shapes, improvement plan produced — 2026-09-03 + +**Status:** Retrospective complete; underlying fixes not yet implemented (deliberately deferred, see below). +Full record: `docs/doctoring/noema-review-failure-retrospective-and-improvement-plan-20260903.md`. + +**What was done.** Re-read all 7 `noema-review-gate` incident sections already in this document (all dated +2026-08-31), all 6 pre-existing Noema-specific `docs/doctoring/` records, and all 5 GitHub issues whose +title names a Noema review-gate failure mode (`.github#1611`, `#1613`, `#1637` open; `#1596`, `#1614` +closed) — full text of each, not just titles or headers. Grouped the resulting 17 incidents by root-cause +mechanism rather than by date, since several incidents on the same date share one underlying defect. + +**Finding: 5 root-cause shapes, one of which is the clear highest-leverage fix.** (1) *Crash-before-repair-boundary* +— 4 incidents where code parsing/decoding an untrusted gateway response ran before `call_llm`'s one +repair-retry boundary, so each new response shape (malformed JSON, non-UTF-8 bytes, truncation, and a +still-open budget-exhaustion variant) crashed the check instead of reaching the safety net one layer over. +(2) *A fix for one bug introduces a different bug* — 2 incidents, including a fail-closed crash fix that +itself leaked LLM output to a public Actions log via an insufficient regex scrubber. (3) *Race-condition +"is this head still live" guards, independently reimplemented in 5 places, each with its own distinct bug* +— the stale-trigger guard, the close-cleanup job, the repair-retry path, the live-head re-check added to fix +repair-retry, and a structurally identical guard in `opencode-review.yml`'s verdict poller. This is the +single most concrete, actionable finding in the whole retrospective: one shared, well-tested +`assert_head_is_live()` primitive replacing all 5 hand-written copies would mean a 6th version of this same +bug has nowhere left to reoccur. (4) *Infrastructure/lifecycle*, not code-logic — 3 incidents (App token +outliving a long review, this document's own item-13 concurrency-group finding, a stale pinned upstream +commit). (5) *Still open, not yet resolved* — `.github#1611`/`#1613`/`#1637` describe overlapping symptoms +of the same underlying gap and are recommended to be fixed as one coordinated PR rather than three +independent patches, to avoid a third instance of shape (2). + +**Not implemented here, deliberately.** All four concrete improvement-plan items in the doctoring +record — a unified response-parsing helper, the unified live-head-guard primitive, one coordinated fix for +the three open issues, and a semgrep rule to catch the two recurring anti-patterns before review finds them +again — are changes to live, security-critical CI logic (`scripts/ci/noema_review_gate.py`, +`noema-review.yml`, `opencode-review.yml`). Consistent with this document's standing practice (see the +item-13 entry above), a documentation-only PR does not bundle a live-workflow-logic change; each belongs in +its own PR with dedicated regression tests reproducing the specific incident it targets. + +**Cross-reference.** The live-head-guard duplication (shape 3) is a fresh instance of the pattern already on +record as `docs/doctoring` and this document's "silently-inactive required check" / duplicated-ad-hoc-guard +family — the same lesson (one shared, correctly-implemented primitive beats N independent reimplementations) +recurring in a new subsystem. + +## Item 7 (EgressWeave/wardnet adoption in contextual-orchestrator) — "zero work started" claim corrected, then own "EgressWeave incompatible" conclusion corrected — 2026-09-03 + +**Status:** Investigated via direct code reading (fresh clone), then re-verified via a 9-agent workflow after +user pushback, then further refined after Devin's automated PR review correctly challenged the redesign +sketch's client-lifecycle/resolver-seam/timeout-scoping details (all three verified against EgressWeave's +source; corrected recommendation now uses only `egressweave.validate_egress_url_details()`, not the full +`build_egress_sync_client()` transport). Not a code change. Full record: +`docs/doctoring/egressweave-wardnet-adoption-audit-contextual-orchestrator-20260903.md`. + +**First correction.** This session had earlier reported item 7 to the user as "손도 안 됨" (zero work started, +architecturally unaddressed). That was wrong for wardnet. **wardnet is already integrated**, for Camoufox +browsing session isolation: `compose.camoufox-wardnet.yaml` routes the isolated +`camofox-browser`/`camofox-mcp` containers' only egress path through wardnet (DNS-pinned egress + +authenticated CONNECT proxy, no published ports) — real, deployed infrastructure backing ADR-0123 (item 14's +foundation), not a design note. + +**Second correction (same day, before merge): the first EgressWeave analysis was itself wrong.** It concluded +"EgressWeave's default SSRF posture is actively incompatible with [local mlx:// provider support], not an +edge case it happens to miss" — based on EgressWeave's README/PyPI listing alone, without checking its actual +policy API. **The user challenged this directly ("버그네") and was right.** EgressWeave ships a documented, +tested "local-development exception" — `EgressPolicy(allow_local=True)` plus a bare single-label hostname in +`allowed_hosts` — verified by reading the real source (`src/egressweave/validation.py:167-202`, +`policy.py:462-475`), its own worked local-LLM example (`docs/security-model.md`'s +`EgressPolicy.from_hosts("ollama", allow_local=True, ...)`), passing tests +(`tests/test_allow_local_security.py`, `tests/test_exact_local_allowlist.py`), and an executed +proof-of-concept confirming one policy instance can simultaneously allow a public provider and a local one. +**The real, narrower issue:** `contextual-orchestrator`'s actual `ModelAgent.base_url` values are raw +loopback IP literals (`mlx://127.0.0.1:8080/v1`), and EgressWeave's allowlist unconditionally rejects an IP +literal as the authority hostname even under `allow_local=True` — so today's exact `base_url` strings can't +be handed to EgressWeave verbatim. **That is a buildable integration task (alias local providers to a bare +hostname, resolve the alias back to loopback), not a library incompatibility** — the distinction the first +analysis collapsed into a blanket "don't adopt" recommendation. + +**Also retracted:** the first pass's claimed "asymmetry" (`ModelClient._resolve_addresses` allegedly missing +public-address filtering that `provider_transport.py` has) was a misreading — it looked only at the raw +DNS-pinning helper and missed that `_validate_provider` (`orchestrator.py:2766-2804`), the actual caller on +every live request path, already applies the identical conditional filtering (loopback-only for confirmed +local providers, public-only otherwise). No undocumented gap exists there. + +**New finding from the correction pass: EgressWeave would close several genuine, previously-unverified gaps +in `ModelClient`'s own transport** — response size bounding (CWE-400) absent on the primary chat and +streaming paths (present elsewhere in the file via `_read_bounded_response`, just not wired to chat), no +outbound request size pre-flight bounding, no phase-split (connect/read/write) timeout enforcement, HTTP +method allowlisting enforced only as a source-code convention rather than at runtime, and redirect rejection +that is an emergent side effect of the transport choice rather than a stated, tested policy. One claim from +this pass is flagged as itself unverified rather than carried forward as settled: whether EgressWeave +actually enforces an "immutable" timeout ceiling was asserted from its feature list, not checked against its +timeout-handling source the way the SSRF/allowlist question was. + +**Cross-reference.** The underlying lesson (verify org-wide state and target-repo code before declaring +something absent) held for the wardnet correction; the EgressWeave correction is a distinct, sharper lesson — +verifying "library X can't do Y" requires reading X's own policy/configuration surface, not just its +README/marketing feature list, before recommending against adoption. Saved to +`feedback_verify_org_wide_before_declaring_unstarted.md`. + +## Org-wide audit: `code-scanning/default-setup` vs. a repository's own advanced-configuration CodeQL workflow — 2026-09-04 + +**Status:** Superseded by a staged central-CodeQL rollout contract. `contextual-orchestrator` was the only +confirmed live instance among the 11 Code Search candidates and repositories inspected directly; it was +already fixed in the same investigation that discovered it +(`contextual-orchestrator` PR #1028's failing "CodeQL analysis" check — `code-scanning/default-setup` was +`state: "configured"` while `.github/workflows/security.yml`'s `codeql_analysis` job also ran a real, +working `github/codeql-action/init` + `analyze` sequence; GitHub rejects that combination outright, failing +the SARIF upload with "CodeQL analyses from advanced configurations cannot be processed when the default +setup is enabled." Fixed with `gh api --method PATCH repos/ContextualWisdomLab/contextual-orchestrator/code-scanning/default-setup -f state=not-configured`, +since `security.yml` was the pre-existing, real coverage mechanism; a related suppression bug found in the +same pass — the whole "Security" workflow, id `300545778`, had been `disabled_manually`, hiding the failure +rather than fixing it — was reversed with `gh api --method PUT .../actions/workflows/300545778/enable`.) + +**Why an org-wide audit was warranted.** The item-41 entry above records that its 2026-09-03 default-setup +rollout deliberately checked real coverage first via the `code-scanning/analyses` API before assigning +default-setup only to the 23 repositories with zero coverage from any source. `contextual-orchestrator` +having both mechanisms simultaneously raised the question of whether it was misclassified during that sweep, +or whether default-setup landed on it (and possibly others) through an unrelated path. + +**Method.** Org-wide `gh api -X GET search/code -f q="codeql-action/analyze org:ContextualWisdomLab path:.github/workflows"` (content search, not a filename grep — the same lesson item-41 already applied, since `contextual-orchestrator`'s own coverage lives in an unexpectedly-named `security.yml` rather than a `codeql.yml`) returned 13 hits across 11 repositories with a local workflow file containing `github/codeql-action/init`/`analyze`: `newsdom-api`, `keyverse`, `ContextualWisdomLab.github.io`, `fast-mlsirm`, `scopeweave`, `bandscope`, `contextual-orchestrator`, `mightyETL`, `litellm-patched-proxy` (2 files), `pg-erd-cloud`, and `.github` itself (2 files — `codeql-scan-dispatch.yml`, the already-known central dispatch handler, and `scheduled-security-scan.yml`; expected, not investigated further as a "local repo" case). `gh api repos/ContextualWisdomLab//code-scanning/default-setup --jq '.state'` was then checked for each of the other 10. + +**Result: `default-setup=configured` alongside a local advanced-config workflow, beyond `contextual-orchestrator`, in exactly 3 repositories — none of which are in item-41's 23-repository rollout list, and none of which are a live conflict.** +- **`ContextualWisdomLab.github.io`** — false positive. Its `.github/workflows/codeql.yml` is named "CodeQL Default Setup Marker," triggers only on `workflow_dispatch` (never on push/PR), and its `analyze` step carries `if: ${{ false }}` (never executes) with an explicit preceding comment: *"Skipping github/codeql-action/analyze because central/default setup owns SARIF upload."* Deliberately engineered to expose `codeql-action` usage to Scorecard's static analysis without ever touching SARIF. No fix needed. +- **`fast-mlsirm`** — false positive. `.github/workflows/codeql.yml` runs two real jobs (`analyze-actions` on every PR, `analyze-python` gated to `workflow_dispatch` only), and **both** `analyze` steps carry `with: upload: never`, with comments stating *"Default setup remains the repository's code-scanning upload owner"* and *"Default setup already owns ordinary Python code-scanning uploads."* Confirmed via a live job log (run `33754939454`, job `100646992008`, `2026-09-04T00:45Z`): `upload: never` present in the action's resolved input dump, `Exported results to SARIF` followed by no upload call, job concluded `success`. Deliberately engineered the opposite way from `contextual-orchestrator`'s fix (default-setup keeps ownership, the local workflow stays silent) rather than the way `contextual-orchestrator` was fixed (local workflow keeps ownership, default-setup disabled) — both are valid resolutions of the same conflict; this repository already had one in place. No fix needed. +- **`scopeweave`** — no live conflict, but two dangling artifacts worth a light cleanup. The workflow with real `init`/`analyze` steps (`.github/workflows/codeql.yml`) is `disabled_manually`, so it never runs and cannot collide with default-setup today. A second, unrelated workflow entry — "CodeQL Required," id `335384625`, `.github/workflows/codeql-required.yml` — is registered `state: "active"` in the Actions API, but the file itself no longer exists on the `develop` default branch (`404` on direct content fetch); GitHub retains the workflow-run registration for a file that has since been deleted, so this entry can never actually trigger. Net effect: default-setup is the sole current CodeQL coverage source for this repository, matching item-41's own "zero coverage from any source" criterion at whatever point `codeql.yml` was disabled — not a misclassification, just a repository whose local workflow went inactive after (or independent of) the rollout. Not fixed in this pass: re-enabling the disabled `codeql.yml` would immediately recreate `contextual-orchestrator`'s exact conflict, so any future re-enable of that workflow must add `upload: never` (matching `fast-mlsirm`'s pattern) or disable default-setup first, whichever this repository's owner intends as the coverage source of record. + +**The remaining 7 repositories** (`newsdom-api`, `keyverse`, `bandscope`, `mightyETL`, `litellm-patched-proxy`, `pg-erd-cloud`, `.github`) all returned `default-setup=not-configured` — no conflict is possible regardless of their local workflow's upload configuration. + +**Conclusion.** `contextual-orchestrator`'s conflict was an isolated incident, not a symptom of a broader misclassification in item-41's rollout (none of the 3 repositories found here with `default-setup=configured` alongside a local workflow were among that rollout's 23 targets) and not evidence of an org policy silently re-enabling default-setup on repositories that already had real coverage. Two of the three already carry a deliberate, working design for this exact conflict (`if: false` / `upload: never`) that predates or is independent of this audit — worth keeping as the reference pattern if this conflict resurfaces elsewhere, in preference to `contextual-orchestrator`'s "disable default-setup" fix when the local workflow does not yet have established real-coverage precedence. + +**Caveat.** This audit trusted GitHub's code-search index for the initial 11-repository candidate list rather than fetching and grepping all 74 repositories' workflow directories individually; code search can lag very recent pushes by a short window. The 10 non-`contextual-orchestrator` candidates it did surface were each verified directly against the live API/content, not from search snippets alone. + +**2026-09-05 staged rollout correction.** The organization now requires the central +`.github/workflows/codeql-pr.yml` through ruleset `18156473`; keeping GitHub's generated +`dynamic/github-code-scanning/codeql` default setup on the same PR spends another CodeQL job set. Removal +must proceed one repository at a time. `scripts/ci/audit_codeql_default_setup_rollout.py` is the read-only +gate: it requires the inherited ruleset and central workflow, binds evidence to the exact PR head, blocks an +active advanced uploader/default-setup collision, and reports either `READY_DISABLE`, `VERIFIED`, `WAIT`, +`ROLLBACK`, or `BLOCK`. A repository advances only after exact-head central CodeQL succeeds. If central +CodeQL fails after default setup is disabled, re-enable default setup before continuing, but only when no +active advanced uploader would make that rollback invalid. `.github`, `noema`, and +`IRT-bibliography-set` are explicit ruleset exceptions and must remain `EXEMPT`, not silently counted as +rollout failures. Run the live collector as +`python3 scripts/ci/audit_codeql_default_setup_rollout.py --repository ContextualWisdomLab/ --pr `; +it uses only authenticated REST `GET` requests and re-reads the PR head after collection to reject a moving +snapshot. + +The xtrmLLMBatchPython pilot is intentionally not yet proof of completion: default setup currently reports +`not-configured`, ruleset `18156473` requires central CodeQL, and PR #292 head +`5f4de312e72da5e1303c701d8e6f65cec7207409` has central run `33904225451`; that run is still `queued`. +The generated default-setup run `33904220801` for the same head was cancelled after the setting change. +No second repository may be changed until the central run reaches an explicit successful terminal state and +the detector reports `VERIFIED` for that exact head. GitHub documents the hard boundary: default setup blocks +CodeQL-generated SARIF uploads from advanced configuration, so rollback must never blindly enable it beside +an active uploader. +## 2026-09-04 org-wide open-PR sweep: severe central Actions capacity congestion confirmed, `noema_review_gate.py`/`strix.yml` confirmed as a multi-PR hot-file collision zone + +**Status:** Investigated via direct read-only Actions API queries and scratch-clone merge attempts against +live `main`; not a code change. This is the 900+ open-PR sweep continuing the standing autonomous PR +review→fix→merge→develop loop; individual PR outcomes are recorded as comments on the affected PRs, not +duplicated here. + +**Finding 1 — severe org-wide Actions capacity congestion, confirmed live, not the already-tracked +`QUEUE_SATURATION_CHICKEN_EGG`/floating-runner-image pattern.** `actions_list` (`list_workflow_runs`, +`status: queued`) returned **`total_count: 1719`** queued workflow runs at once, against **`total_count: 2`** +`in_progress`. Spot-checked several PRs' check runs directly: most jobs (`CodeQL`, `Bandit`, `pip-audit`, +`Semgrep`, `trivy-fs`, `scorecard`, `strix`, `noema-review`, `opencode-review`, the merge scheduler's own +`Required PR Review Merge Scheduler` runs) sat `queued` for anywhere from ~20 minutes to over 2.5 hours +(e.g. `#1817`'s own checks, still `queued` since `2026-09-03T22:53:57Z`, ~2.5h before this snapshot); a +minority of lightweight jobs (`Detect changed scope`, `gitleaks`, `validate`) did complete normally in the +same window. This is consistent with a hosted-runner concurrency ceiling being exhausted by simultaneous +demand from the now-100+-PR open queue on this repository alone, compounded across every sibling repository +the same central required workflows also run in. No fix attempted here — this is an Actions plan/concurrency +capacity condition, not a workflow or script defect; per the standing operating directive, a merely-queued +job is never re-run. Recorded so a future session does not mistake near-universal `queued` check state across +dozens of otherwise-healthy PRs for something wrong with those PRs. + +**Finding 2 — `scripts/ci/noema_review_gate.py` and `.github/workflows/strix.yml`/`noema-review.yml` are +active multi-PR hot-file collision zones; at least 6 open PRs each carry a materially different, mutually +incompatible design for the same mechanism.** Attempted the standard `git merge --no-edit` conflict repair +against 8 `dirty`/stale-conflicting PRs this session; 2 succeeded cleanly (`#1187`, `#933`, `#1685` — ordinary +append-only doc/changelog drift or one confirmed-stale carried-forward test assertion, all pushed with full +green suites) and 6 could not be resolved without guessing on a required security gate: + +- `#1198`, `#1606`, `#1589` each modify `scripts/ci/noema_review_gate.py`'s core verdict/response-format or + `inspect_and_review()` control flow, and `origin/main` has independently evolved a *fourth*, different + version of the same surface (`inspect_and_review(repo, number, expected_head)` + + `require_expected_head()`, and separately `_noema_verdict_response_format()` / `_required_probe_count()` — + neither of which any of the three PRs know about, and none of which the three PRs agree with each other + on either). +- `#939`, `#1009` both modify `.github/workflows/strix.yml`'s provider/model-behavior-error retry + classification, and `origin/main` has *already independently shipped* a materially more advanced version + (bounded retry loop, `model_behavior_error_signal`, `is_model_behavior_error()` in + `scripts/ci/strix_quick_gate.sh`) that appears to make significant parts of both PRs' own core + contribution redundant — confirmed via direct `git show origin/main:... | grep`, not inferred from PR + prose. +- `#1674`'s conflict footprint is a single ordinary doc hunk, but a full-suite run *after* the clean merge + (before any push) surfaced 10 failing tests: `origin/main` independently added a + `noema-review.yml` step ("Reject a stale trigger before credential or model setup", part of the same + `expected_head` mechanism above) that this branch has no knowledge of, and git's 3-way text merge silently + dropped it with **no conflict marker at all** rather than flagging a collision — a strictly more dangerous + failure mode than a marked conflict, since a naive merge-and-push here would have shipped a workflow + missing a real fail-closed check with a clean-looking `git merge` exit code. +- `#1158` shows the same shape one layer down in `.github/workflows/security-scan.yml`: this branch replaced + the third-party `google/osv-scanner-action` invocation with a self-controlled `run-osv-scanner.sh` script + plus result-completeness classification at all four OSV call sites; `origin/main` has not adopted that + redesign at all (the script doesn't exist anywhere on `main`) and has continued evolving the + action-based path independently. `#1257` (small, `mergeable_state: blocked`, main-architecture-compatible) + may already close the actual underlying bug (OSV results lost across fork checkout) this branch was opened + for, without needing the larger rewrite reconciled at all. + +**Why this matters beyond the 6 individual PRs.** These are not isolated stale branches — they are 6+ +independent lines of development racing on the same 3 files (`noema_review_gate.py`, `strix.yml`, +`security-scan.yml`) simultaneously, each written by a different agent/session across roughly 2-4 weeks, +each with its own extensive TDD/evidence narrative, and none aware of the others' now-already-merged (or +also-still-open) changes to the same functions. Per-PR comments with the specific evidence were left on each +(`#1198`, `#1606`, `#1589`, `#939`, `#1009`, `#1674`, `#1158`) rather than guessing a text-level resolution +on a required security gate, consistent with this loop's existing standard for `#1279`/`#1280`/`#1382`. The +actionable follow-up is a design-aware reconciliation pass — deciding, per hot file, which in-flight PR (if +any) should become the surviving lineage and which should be closed/rebased against it — not another +automated merge-conflict sweep; a ninth or tenth independently-conflict-resolved branch on the same 3 files +would only add another incompatible lineage to reconcile later. + +**Corroborating context already on this loop's radar.** `#1661` (currently open, `mergeable_state: blocked`, +141 commits) documents having *already* fixed one instance of this exact class in `noema-review.yml` +(the "Cancel superseded Noema runs after live-head validation" concurrency-deadlock extraction) — i.e. the +pattern of multiple sessions independently repairing the same hot file is already a known, recurring shape +in this specific workflow, not a one-off. + +## 2026-09-04 follow-up: 4 more PRs confirmed in the hot-file collision zone (`strix.yml`, `pr_review_merge_scheduler.py`, `noema_review_gate.py`); one genuine pre-existing test bug found and fixed elsewhere + +Continuing the same round's PR sweep, four additional open PRs hit real merge conflicts whose root cause is +the same class documented above — main has independently evolved a materially different, incompatible +design for the same mechanism since each branch's last sync — rather than a resolvable text collision. +Evidence-based comments were left on each; no guessed resolution was pushed on any of them. + +- **`#1065`** (`fix(scheduler): fall back to REST when auto-rebase GraphQL transport fails`) conflicts in + `.github/workflows/strix.yml`: its branch still has the older neutral-skip design (a backend-unavailable + signal with no reported vulnerability prints a warning and `exit 0`), while `origin/main` has since landed + a stricter fail-closed `STRIX_PROVIDER_UNAVAILABLE` design (new `strix_neutralization_scope_log` log-tail + isolation, a new `model_behavior_error_signal` classification, `exit "$strix_rc"` instead of a neutral + pass). A text merge here would either silently downgrade the since-hardened gate back to a neutral skip, + or require guessing which parts of two designs to keep. +- **`#1271`** (`fix(scheduler): fail after summarized action errors`) and **`#1231`** + (`fix(scheduler): isolate central Actions inventory quota`) both edit `scripts/ci/pr_review_merge_scheduler.py` + directly — a **4,074-line monolith** on each branch's own version of that file — while `origin/main` has + since landed the facade/core split from `#1803`: `scripts/ci/pr_review_merge_scheduler.py` is now a + **241-line** thin re-export shim, and the ~5,700 lines of real implementation live in the new + `scripts/ci/pr_review_merge_scheduler_core.py`, which main has continued to evolve independently of either + PR. A text-level `git merge` cannot reconcile "edit function X in the 4,074-line monolith" against "that + file is now a 241-line shim and X's body moved to a different file main also changed since." `#1231` + additionally carries its own already-documented external stack dependency on `#1213`. +- **`#1681`** (`fix(noema): require finding-level confidence, not just severity`) conflicts in + `scripts/ci/noema_review_gate.py`: its branch still carries the pre-"single-request-gateway" retry/repair + structure (`is_retry`, `deadline_context = _repair_wall_clock_deadline(...)`, an inline `json.dumps(...)` + schema restated in the prompt text), while `origin/main` landed the 2026-09-02 "Noema single-request + gateway ownership" restructuring (see `CHANGELOG.md`) that removed the repository-owned repair deadline + outright, made the LLM call single-request with `contextual-orchestrator` owning repair/failover, added + `active_phase`/`served_model` telemetry, and moved the findings schema into `response_format` rather than + prompt text. The PR's actual payload (a `confidence` field alongside `severity`) is small and valuable but + expressed against code structure that no longer exists in that shape on `main`. + +This raises the confirmed hot-file collision count from 7 PRs (`#1198`, `#1606`, `#1589`, `#939`, `#1009`, +`#1674`, `#1158`) to 11, and confirms `scripts/ci/pr_review_merge_scheduler.py`'s new facade/core split +(`#1803`) is now *also* an active collision surface in the same way `noema_review_gate.py`/`strix.yml` are — +the same underlying dynamic (many long-lived branches, each written by a different agent/session, racing on +the same central files without visibility into each other's now-merged changes) recurring in a third +subsystem. No fix attempted for the file-shape divergence itself here, consistent with this document's +standing practice of not bundling live-workflow-logic changes into a documentation-only entry. + +**Separately, one genuine pre-existing (not merge-caused) bug was found and fixed while merge-repairing +`#1655`** (`fix(review): keep OpenCode uncertainty schema-representable`): its new end-to-end test +(`tests/test_opencode_uncertainty_model_pool_transport.py`) asserted byte-exact equality between a fake +model's export text and the file `scripts/ci/run_opencode_review_model_pool.sh` writes via `jq -r`. `jq` +always appends a trailing newline after printing a value, so model text that itself already ends in `"\n"` +legitimately produces one extra trailing blank line — harmless in production (both the bash pool's own +`is_current_run_needs_info_output` check and the Python normalizer strip blank lines before comparing), but +the test's exact-equality assertion didn't account for it. Confirmed pre-existing (not something the main +merge introduced) by running the test against the PR's pristine, unmerged head before merging. Separately, +`scripts/ci/opencode_review_normalize_output.py`'s new needs-info transport wrapper had two branches +exercised only by subprocess-invoking tests, which `coverage.py` cannot see across a process boundary, +leaving 2 statements/branches short of the required 100%; added direct in-process unit tests covering both. +Both fixes are test-only; pushed as part of `#1655`'s merge-repair commit. + +## 2026-09-04 Actions-capacity and startup-failure follow-up + +The earlier 1,719-run snapshot was incomplete. A repository-by-repository REST census across all 74 visible organization repositories found 5,991 queued and 47 in-progress runs. After removing duplicate central quality jobs, retiring organization-wide run cancellation, and cancelling only review/security runs that had remained in progress for more than six hours, the queue fell as low as 5,471 while active admission recovered to 45–50 jobs. Later merge-triggered work can temporarily raise the queued count, so this is evidence of renewed throughput, not a claim that the backlog is gone. + +The same census queried `status=startup_failure` across all repositories. It returned 404 historical rows in 56 repositories; every newest row was the old centrally injected `CodeQL PR` failure, with the latest at 2026-09-03T03:26:53Z. The required-workflow form had embedded `github/codeql-action`, which GitHub rejected before creating jobs or logs. Central PRs #1776 and #1778 moved execution to the native dispatch workflow and removed the failing workflow from the organization required list. A current wardnet PR materialized both Actions and Rust CodeQL jobs after that change, and the organization census found no later startup-failure type. Item 41 is therefore fixed for the observed organization scope; future startup failures remain fail-closed regressions rather than tolerated queue states. + +## Hourly review-repair `max_prs` cap: live and unfixed for all 20 targets — 2026-09-03 + +**Status:** Root-caused and fixed. `.github/workflows/hourly-review-repair.yml` (the single file that +replaced 18 per-repository callers, see `docs/doctoring/hourly-review-repair-single-file-consolidation.md`) +called `pr-review-fix-scheduler.yml` with `max_prs: "50"` for all 20 targets. `#1397` had already root-caused +this exact bound as too low for BandScope specifically (136 open PRs at the time, so an oldest-first scan +capped at 50 never reached current non-draft work), but that PR never merged before the consolidation deleted +its target file out from under it — leaving `#1397` obsolete and the underlying cap live, org-wide, and +unfixed. Independently confirmed live during this session's PR sweep: `ContextualWisdomLab/.github` itself +(one of the 20 targets, `21 * * * *`) had 117 open PRs. Fixed by discovering up to 200 PRs while deeply +inspecting a deterministic rotating window of 50, then stopping after the single permitted dispatch; see the +doctoring doc's 2026-09-03 follow-up section for the full before/after and updated tests. +A comment was left on `#1397` pointing at the replacement fix rather than closing it (closure is a merge-only +action per this repo's governance model). + +## `opencode-review-dispatch.yml` still requesting the starved floating image — 2026-09-04 + +**Status:** Fixed. The 2026-09-01 floating-image entry above closed the three required-check gates +(`strix.yml`, `opencode-review.yml`, `noema-review.yml`) but explicitly flagged "any remaining unpinned +central workflows" as an open follow-up. `opencode-review-dispatch.yml` — the workflow the required +`opencode-review` check's own `repository_dispatch` lands on to actually run the OpenCode CLI and post the +exact-head verdict — still requested `ubuntu-latest` on all 4 jobs. Confirmed live on +`contextual-orchestrator#1017`: its dispatch run (`33916313804`) sat `queued` with no runner ever assigned +from creation, and a 30-run sample of recent `opencode-review-dispatch.yml` runs org-wide showed 14 still +`queued` (several 10+ hours old) and 0 clean successes in the sample. Pinned all 4 occurrences to +`ubuntu-24.04` and extended `tests/test_required_review_runner_image_contract.py` with a fourth case. + +**Residual.** The rest of `.github/workflows/` still has unpinned `ubuntu-latest` jobs (`pr-review-autofix.yml`, +`pr-review-fix-scheduler.yml`, `hourly-review-repair.yml`, `codeql-pr.yml`, `codeql-scan-dispatch.yml`, and +others) — this fix deliberately stayed scoped to the one file with direct, confirmed live evidence of +starvation rather than a speculative sweep of every remaining occurrence. Worth revisiting each individually +if queuing symptoms recur on them specifically. + +**Residual closed, 2026-09-05 — but does not explain today's dominant congestion.** Symptoms recurred (a +severe, hours-long org-wide Actions stall) and all five named files, plus `python-security.yml` (found +independently while investigating the same symptom, not previously named here), were confirmed still +requesting `ubuntu-latest`. Pinned all six to `ubuntu-24.04` (10 total job occurrences) and added +`tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py` covering all six. **This does not, +by itself, explain today's stall**: a direct query of `.github`'s own queued-run backlog (307 queued, +confirmed via `actions/runs?status=queued`, cross-checked against `status=in_progress` returning only +5-6 -- itself anomalous against the documented 60-job Team-plan ceiling, since 5-6 is far below 60) showed +the dominant contributors by far were `Required PR Review Merge Scheduler` (~32 of a ~300-run sample), +`Python Security` (~29), `CodeQL PR` (~25), `Security Scan` (~23), `SAST Semgrep` (~20), and `Agent Review +Runtime Quality CI` (~16) -- and four of those six (`pr-review-merge-scheduler.yml`, `security-scan.yml`, +`sast-semgrep.yml`, `agent-review-runtime-quality-ci.yml`) were *already* pinned to `ubuntu-24.04` before +this pass, per their own existing contract tests, and equally stuck. GitHub's own status page showed no +active incident at the time. The 5-6-vs-60 in-progress gap therefore remains unexplained -- not resolved +by this fix, not attributable to a known starved image, and not (per prior explicit ruling; see +`project_actions_plan_concurrency_ceiling.md`) a case for proposing paid additional capacity. Flagging +for whoever investigates next: check org-level Actions settings (a policy-level concurrent-job cap below +60), a spending/usage limit (though billing access was unavailable to verify), or a GitHub-side runner +provisioning degradation not severe enough to reach the public status page. + +**Separately found while validating this fix, not yet fixed:** `tests/test_pr_review_autofix_nvidia_nim_contract.py::test_review_fix_caller_runs_once_each_hour` +fails on a clean `origin/main` checkout, independent of this fix — `hourly-review-repair.yml` was renamed to +"Daily Review Recovery" and redesigned from one hourly cron to 17 staggered daily crons (one per target +repository), but this test still asserts the old single hourly `cron: "23 * * * *"`. Same bug class as the +`test_strix_quick_gate.sh` org-sweep-cron staleness found and fixed on `#1503` the same day: a test left +behind by a workflow redesign. Needs its own fix understanding the new staggered-daily design's actual +intended contract before rewriting the assertion — left for a dedicated follow-up rather than guessed at here. + +## Items 15/16/17 measurement: `Detect changed scope` gate jobs — 2 of 3 are pure runner overhead — 2026-09-05 + +**Status:** Measured, not yet fixed. Recorded so the fix is grounded in real numbers rather than the intuition +this measurement partly refuted. + +**Why measured.** Items 15/16/17 ask to remove needlessly-triggered workflows, consolidate workflow files +("bootup에도 시간이 듦"), and cut redundant steps; the standing complaint is the org's 60-concurrent-job +ceiling ([`docs/doctoring/actions-plan-concurrency-ceiling-20260903.md`](doctoring/actions-plan-concurrency-ceiling-20260903.md)). +Reducing *jobs per PR* attacks that ceiling directly, so jobs-per-PR was taken as the metric. + +**Baseline, measured live.** One completed `.github` PR head (`#1829`) produced **57 check runs across 2 run +attempts — roughly 28 per attempt**. `Detect changed scope` was the single most repeated job name (10 total, +**5 per attempt**), well ahead of anything else. + +**The intuition ("5 duplicate gates = 5 wasted runners") is wrong; the corrected finding is narrower.** Each +gate job allocates a full `ubuntu-24.04` runner and makes a retrying paginated `gh api .../pulls/N/files` +call purely to compute two booleans (`code`, `deps`). Whether that cost is waste depends entirely on how many +consumers `needs:` it — which differs per file: + +| Workflow | Gate consumers (`needs: changed-scope`) | Verdict | +| --- | --- | --- | +| `security-scan.yml` | 4 (`osv-scan`, `dependency-review`, `trivy-fs`, `scorecard`) | **Legitimate.** One runner amortized across 4 gated jobs; self-gating each consumer would trade 1 runner for 4 redundant API calls. Keep. | +| `sast-semgrep.yml` | 1 (`semgrep`) | **Pure overhead.** Two runner allocations where one suffices. | +| `strix.yml` | 1 (`strix`, which also needs `admit-current-head`) | **Pure overhead.** Same shape. | + +**Quantified opportunity.** Folding the gate into its single consumer as an early-exit first step saves +exactly **1 runner allocation per workflow per PR** in the two single-consumer cases — **2 slots per PR** — +with no extra API calls (the same lone consumer computes the same booleans it already waited on). The saving +lands on code-touching PRs; a doc-only PR allocates one runner either way (gate-then-skip vs. run-then-exit). +Both files are org-ruleset required workflows dispatched into ~74 repositories, so this is 2 slots per PR +**org-wide**, against a 60-slot ceiling. + +**Constraint any fix must preserve.** The gate exists because the org ruleset ignores every `on:` filter when +it dispatches these workflows into another repository, and a trigger-level skip leaves `.github`'s classic +required contexts Pending forever — the job-level decision is load-bearing, not incidental +([`docs/doctoring/required-workflow-path-filter-boundary.md`](doctoring/required-workflow-path-filter-boundary.md)). +Early-exit-inside-the-consumer keeps that property (the job still runs and concludes `success`), but any fix +must be checked against it explicitly rather than assumed. + +**Not fixed here, deliberately.** These are live org-wide required workflows and the org's CI pipeline is +currently unable to complete runs at all (see the pipeline-stall entry), so the change cannot be validated +end-to-end right now, and ~30 PRs are already queued behind the same stall. The measurement is recorded now +because it is the part that is durable and currently unclaimed; the edit belongs in its own PR with the +local workflow-contract tests run against it. + +**Extension (2026-09-05): two echo-only jobs sit serially on the OpenCode review critical path.** Credit to +a peer session's read-only Codex pass for spotting the first of these; independently verified here against +`origin/main` and extended with this session's own queue-latency measurements. + +`opencode-review.yml` defines a five-deep serial chain — +`required-workflow-bootstrap` → `admit-current-head` → `coverage-source-tree` → `coverage-evidence` → +`opencode-review-target` — in which **two links do nothing but print a string**. `coverage-source-tree` +(`:279`) allocates an `ubuntu-24.04` runner to `echo` that execution is delegated elsewhere; +`coverage-evidence` (`:289`) allocates another to `echo` that it "preserves the stable branch-protection +context without executing pull-request content". Each is a full runner allocation, and because a job is only +created once its `needs:` predecessor finishes, **each link pays a fresh queue wait under saturation.** + +**Measured cost, from this session's item-13 evidence audit of `ContextualWisdomLab/naruon#1528` +(run `33581213805`).** Per-job `created_at` → `started_at` on that run: `required-workflow-bootstrap` ~7h57m, +`coverage-source-tree` **~9h40m**, `coverage-evidence` **~13h1m**, `opencode-review` ~12h13m. The two +echo-only links contributed roughly **22h41m of pure queue latency to a single PR** — not runner-seconds +spent working, but wall-clock spent waiting for a slot in order to print a sentence, while holding the actual +review behind them. + +**The contexts are load-bearing; the serialization is not.** Both jobs exist to keep a required +branch-protection context reporting, the same structural constraint as the `changed-scope` gates above, so +neither can simply be deleted. But nothing in either job produces an output the next one consumes: their +`needs:` edges are ordering, not data dependency. Running both in parallel off `admit-current-head`, and +dropping `coverage-evidence` from `opencode-review-target`'s `needs:`, would preserve every reported context +while removing two sequential queue waits from the critical path. + +**The serialization mechanism is confirmed, not inferred.** A peer session independently re-pulled the same +run and found each job's `created_at` is *exactly* its predecessor's `completed_at` (e.g. `coverage-source-tree` +created `09:52:19Z` = `required-workflow-bootstrap` completed `09:52:19Z`). A job is therefore not queued at +all until its `needs:` predecessor finishes, so every link pays a fresh, full queue wait. Against execution +times of **4 and 5 seconds**, those two links waited 9h40m and 13h1m. + +**The order-dependency question this entry originally left open is now answered: nothing depends on the +order.** Verified by that peer session across three surfaces — no test asserts the `needs:` chain order +(`test_strix_quick_gate.sh` mentions both names, but as set membership in a fast-approval ignore list, not an +ordering claim); the merge scheduler reads only a context *name* and its exact-head conclusion +(`scripts/ci/opencode_coverage_identity.py`'s `CANONICAL_CHECK_NAME = "coverage-evidence"`), never when it +ran; and neither job declares `outputs:`, confirming the edges carry ordering rather than data. + +**One safety condition any fix must honour, which this entry's first draft missed.** `coverage-evidence` +declares no `if:` of its own — it is skipped only *transitively*, because `coverage-source-tree` carries +`if: needs.admit-current-head.outputs.admitted == 'true'` and a skipped `needs:` predecessor skips it too. +Cutting that edge without moving the guard would let a required context execute on an unadmitted head. +The complete change is therefore: give `coverage-evidence` `needs: [required-workflow-bootstrap, +admit-current-head]` **plus that same explicit `if:`**, and reduce `opencode-review-target` to +`needs: [admit-current-head]` — safe on the admission axis because that job already carries the identical +`if:` guard directly. Chain depth drops from five to three, and queue waits from four to two. + +**Second safety condition, and the sharper trap: two different workflow files define jobs with these exact +names, and only one pair is safe to touch.** `opencode-review.yml` (required, `pull_request_target`) holds the +echo-only placeholders analysed above. `opencode-review-dispatch.yml` (privileged, `repository_dispatch`) +defines `coverage-source-tree` (`:206`) and `coverage-evidence` (`:352`) that do the **real** work: the former +exchanges an app token, materializes the PR merge tree, and `upload-artifact`s it (`:344`); the latter runs +with `timeout-minutes: 300` and `download-artifact`s that same tree (`:429`), as its own comment states — +*"The PR tree arrives through a same-run artifact."* There, the `coverage-source-tree` → `coverage-evidence` +edge is a hard data dependency, not ordering, and cutting it would break coverage measurement outright. **Any +parallelization must be confined to `opencode-review.yml`.** This distinction was missed by two sessions +independently — both reasoned about "the coverage jobs" without checking that the name resolves to two +different jobs in two files — and was caught only by opening +`scripts/ci/test_strix_quick_gate.sh`, whose assertions at `:959-963` describe `coverage-source-tree` as +materializing and uploading a merge tree, contradicting "it only echoes" and exposing the second file. A read-only +cross-family (Codex) pass over both files independently reproduced all three points, adding the artifact name +this record had not cited (`opencode-coverage-source`, uploaded at `:344-350`, downloaded at `:429-433`). + +**Implemented, scoped correctly: `ContextualWisdomLab/.github#1910`** cuts the chain from five serial links to +three (queue waits per PR from four to two), confined to `opencode-review.yml`, carrying the explicit +admission `if:` onto `coverage-evidence`, and dropping `coverage-evidence` from `opencode-review-target`'s +`needs:` after confirming that job never reads the context at runtime — its only mention was the `needs:` line +itself, and the real consumer (`opencode-review-dispatch.yml` via `scripts/ci/opencode_coverage_identity.py`) +queries the check-runs API at its own time, order-independently. The implementing session noted honestly that +their change was safe because they had scoped it narrowly, not because they had checked for the name +collision — which is the more useful lesson: **a job name is unique only within one workflow file, and the +same name in another file can carry the opposite safety property.** diff --git a/opencode.jsonc b/opencode.jsonc index 3d2a492b60..8946175a13 100644 --- a/opencode.jsonc +++ b/opencode.jsonc @@ -286,96 +286,6 @@ } } }, - "nvidia-nim": { - "npm": "@ai-sdk/openai-compatible", - "name": "NVIDIA NIM", - "options": { - "baseURL": "https://integrate.api.nvidia.com/v1", - "apiKey": "{env:NVIDIA_API_KEY}" - }, - "models": { - "nvidia/llama-3.3-nemotron-super-49b-v1.5": { - "name": "NVIDIA Llama 3.3 Nemotron Super 49B v1.5", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "nvidia/llama-3.1-nemotron-ultra-253b-v1": { - "name": "NVIDIA Llama 3.1 Nemotron Ultra 253B", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "nvidia/nemotron-3-super-120b-a12b": { - "name": "NVIDIA Nemotron 3 Super 120B", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "nvidia/nemotron-3-ultra-550b-a55b": { - "name": "NVIDIA Nemotron 3 Ultra 550B", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "meta/llama-3.3-70b-instruct": { - "name": "Meta Llama 3.3 70B Instruct (NIM)", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "meta/llama-3.1-70b-instruct": { - "name": "Meta Llama 3.1 70B Instruct (NIM)", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "deepseek-ai/deepseek-v4-pro": { - "name": "DeepSeek V4 Pro (NIM)", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "mistralai/mistral-large-2-instruct": { - "name": "Mistral Large 2 Instruct (NIM)", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - }, - "mistralai/codestral-22b-instruct-v0.1": { - "name": "Codestral 22B Instruct (NIM)", - "tool_call": true, - "limit": { - "context": 32768, - "output": 8192 - } - }, - "google/gemma-4-31b-it": { - "name": "Gemma 4 31B IT (NIM)", - "tool_call": true, - "limit": { - "context": 131072, - "output": 8192 - } - } - } - }, // Org default (org policy 2026-08-18): OpenCode reviews route through the // vendored contextual-orchestrator LLM gateway. It auto-discovers models // across Bytez/NVIDIA NIM (x2 keys)/OpenRouter/OpenAI from KV-registered diff --git a/requirements-opencode-review-ci-hashes.txt b/requirements-opencode-review-ci-hashes.txt index 75908a47db..d8aaca3ad8 100644 --- a/requirements-opencode-review-ci-hashes.txt +++ b/requirements-opencode-review-ci-hashes.txt @@ -245,24 +245,24 @@ tabulate==0.10.0 \ --hash=sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d \ --hash=sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3 # via interrogate -uv==0.11.25 \ - --hash=sha256:2c1cfe97dce56c997dfa3214bdb8955b7b34cceea7505520185e22ad99c0eb6b \ - --hash=sha256:3febca65ec5bc336ddaf7e4f724704f2c894c16839723df14865ee00b4acf38d \ - --hash=sha256:41b37e724f41eb4c3794bbdd82ddeebb4b5850d4ada8cccb2906ef9e5aa0f83b \ - --hash=sha256:458e731778e7b5cc870710397859c23e766703e7bc0695f23b3eb15080745ba6 \ - --hash=sha256:560b0fbaa6356af533923a349658c21d4f410d16e835787d8a05da451d4ee859 \ - --hash=sha256:57fbd47e924242fd347d0c209d95711d8ea61db8d8780962d0f30ccde2c854a3 \ - --hash=sha256:610650cbaa0a9b18015da39d2c28d736d287a5a124e49296d8fdef5e4022e980 \ - --hash=sha256:61ef11d9967a38109e6e8e3d20d1f743fa08033c32bce274d6ccd9a9abb5d305 \ - --hash=sha256:69d14ffd0a4b050f8a70f64aacb09b8dfdfb1cb30a6351fb17b48f273f95c58c \ - --hash=sha256:79f166cd1b84f855e9d2768221d59b403869648289fd884d58ad4299edfb4d9e \ - --hash=sha256:850ba0018ff170c3a9baaf9b5fe8b23393b6b77ee4ea6b2e2315fdb8d7c388f7 \ - --hash=sha256:86d4759fec9b46f61944d6e9ef1f5eaa2c5fbe2db5ddb59492d9174b08fcf39c \ - --hash=sha256:b180b12237b4e04692491fc6796584a9a8bdf4c7332bd2a769caf096b97885d0 \ - --hash=sha256:d2bc05e17ae3e1f232abf93e7dcfb3b68702dfcde34a00c29cbce7e07d1ecbfb \ - --hash=sha256:d6f965a79fc7539a12139ce981caa0cbf7d9d3bd4ea3daadaf174ab4d7fb6e42 \ - --hash=sha256:e3480640983e0b8e509eeb67882837e620bdd820f8776948a5f13ebbb4481d04 \ - --hash=sha256:f42de9e7d63a28a4fe76a522077813656de38b5acda20b4db63857d260c1ff13 \ - --hash=sha256:f7a78fc8d0c5e764e9fa39c99066db47a0bc465b023feed90812e3c0a6b5eb0d \ - --hash=sha256:fbff70ae9fa4da9fb6823ae4fdaf77a65c9520e13b6d1d0241ba56e4b121b7aa +uv==0.12.7 \ + --hash=sha256:016fe4b9a2e0d2a35b17b6c3efbb45b929189c5b4b37aa921265265ccfe42cc1 \ + --hash=sha256:0ad3e91cc911596bb54197057853b64b36a066462d8f2fc4d4f60b61b707ffa8 \ + --hash=sha256:1014a13854c45eb1daa9a32602e0f4d07f3edd298826d3e8d22740eed60a7c95 \ + --hash=sha256:277d326d7e63b912f3425c6e6d7d5d49f21b43d080d21859ff3c6819353f1847 \ + --hash=sha256:36c8f93d182b766b9ed4a9c1da5ec0f7dc9f934887df3404d996f66321fd18d5 \ + --hash=sha256:3ac3321ccd6097dbef154d27044e0762a67b2f6eb017dcc65be6574f4671fb0d \ + --hash=sha256:4545e87c7ac64af317d8daffd279e23e93b0e05035662363033d3525923339d2 \ + --hash=sha256:4b320f84763a80308fd830ecf5c4c44505a8ed910fe265c5977d0a3727cfcd55 \ + --hash=sha256:56a5730f8eff477501b3276a0059c2c2843302d5d4a6cc10f993a5cd66ddace8 \ + --hash=sha256:95c3a4fa65e72bab3ca1b4c8ce18fbe784cf3137e9f9234588b69d09b341a4a7 \ + --hash=sha256:b2bd0f25f17f0000a2415347471e713cd1597f4525cd3412d17875b131f4b1ac \ + --hash=sha256:b6d4bd67b488ef2766cfa885947c1093c18caf5d665ba9156963ad0241f196e9 \ + --hash=sha256:d568fd3448c24354753fd8333c978aeeeb6b51db4b100e234461a7eb50882fae \ + --hash=sha256:d83419298e202f56e381cef6406b519b9336e58fc90a559617d86576a3d8a4e8 \ + --hash=sha256:debeccc5eca0063cd922bc67caa4a8c0df5f69090179866ed17fa7264905bda2 \ + --hash=sha256:ec5b437aa60e8c94da263ad709d0bf6c8f268ac81f305d89c6115badd7d1cbe7 \ + --hash=sha256:fc57436f2f012b885454f465dbf077f573745f0d4275a6a38194203b67e94ea4 \ + --hash=sha256:fe9a871bd638ee6d2fd73bf40c2ee98153e44d06f796a03fcecf9d12b36d42d8 \ + --hash=sha256:ff33305718665c6fba25efdd260c67a6bd500c665e3d5d61059612791ca10c90 # via -r requirements-opencode-review-ci.txt diff --git a/requirements-opencode-review-ci.txt b/requirements-opencode-review-ci.txt index fa1e2b5c7d..1e9a42f6a0 100644 --- a/requirements-opencode-review-ci.txt +++ b/requirements-opencode-review-ci.txt @@ -6,4 +6,4 @@ hypothesis>=6.100 interrogate==1.7.0 pytest==9.1.1 pytest-cov==7.1.0 -uv==0.11.25 +uv==0.12.7 diff --git a/requirements-strix-ci-hashes.txt b/requirements-strix-ci-hashes.txt index e0e6f05183..9e705850b5 100644 --- a/requirements-strix-ci-hashes.txt +++ b/requirements-strix-ci-hashes.txt @@ -147,6 +147,7 @@ anyio==4.14.0 \ # google-genai # gql # httpx + # httpx2 # mcp # openai # sse-starlette @@ -922,6 +923,7 @@ h11==0.16.0 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 # via # httpcore + # httpcore2 # uvicorn hf-xet==1.5.1 \ --hash=sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6 \ @@ -954,6 +956,10 @@ httpcore==1.0.9 \ --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 # via httpx +httpcore2==2.12.0 \ + --hash=sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb \ + --hash=sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648 + # via httpx2 httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad @@ -967,6 +973,10 @@ httpx-sse==0.4.3 \ --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d # via mcp +httpx2==2.12.0 \ + --hash=sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf \ + --hash=sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36 + # via openai huggingface-hub==1.20.0 \ --hash=sha256:56df2af3a2a1162469e2e7ab09777aaa359ee080b5395d60e9afac78bc5950ed \ --hash=sha256:8dae0cdaef71fef5f96dc4f0ba47d050c6cef42739f097b858157c092a7a3cab @@ -977,6 +987,7 @@ idna==3.18 \ # via # anyio # httpx + # httpx2 # requests # yarl importlib-metadata==8.9.0 \ @@ -1385,6 +1396,7 @@ openai==2.54.0 \ --hash=sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b \ --hash=sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa # via + # -r requirements-strix-ci.txt # litellm # openai-agents # strix-agent @@ -2303,6 +2315,12 @@ tqdm==4.68.3 \ # via # huggingface-hub # openai +truststore==0.10.4 \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + # via + # httpcore2 + # httpx2 typer==0.25.1 \ --hash=sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89 \ --hash=sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc diff --git a/requirements-strix-ci.txt b/requirements-strix-ci.txt index 23d1c65681..19093441e9 100644 --- a/requirements-strix-ci.txt +++ b/requirements-strix-ci.txt @@ -1,7 +1,8 @@ strix-agent==1.5.3 +openai[httpx2]==2.54.0 aiohttp==3.14.3 google-cloud-aiplatform==1.133.0 -protobuf<7.0.0 +protobuf<8.0.0 cryptography==50.0.0 python-multipart==0.0.32 pyasn1==0.6.4 diff --git a/scripts/ci/agent_mention_router.py b/scripts/ci/agent_mention_router.py index 6466c90218..46332cfc2f 100755 --- a/scripts/ci/agent_mention_router.py +++ b/scripts/ci/agent_mention_router.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import concurrent.futures import hashlib import json import os @@ -463,24 +464,47 @@ def dispatched_agents( artifact_cache = ( ledger_artifact_cache if ledger_artifact_cache is not None else {} ) + + def _fetch_agent(agent: str) -> None: + """Fetch and cache the exact-name artifact lookup for one agent.""" + artifact_name = agent_ledger_artifact_name(request, agent) + response = dispatch_client.request( + [ + LEDGER_ARTIFACTS_ENDPOINT, + "-X", + "GET", + "-f", + f"name={artifact_name}", + "-f", + "per_page=100", + ] + ) + artifact_cache[artifact_name] = bool( + _artifact_records(response, expected_name=artifact_name) + ) + + agents_to_fetch = [ + agent + for agent in candidates + if agent_ledger_artifact_name(request, agent) not in artifact_cache + ] + if len(agents_to_fetch) <= 1: + for agent in agents_to_fetch: + _fetch_agent(agent) + else: + # Bounded concurrency for an otherwise-sequential N+1 network fetch. + # list(executor.map(...)) already blocks until every submitted call + # finishes (or raises) before this function proceeds, so shutdown's + # own wait has nothing left to wait for on the success path. + executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) + try: + list(executor.map(_fetch_agent, agents_to_fetch)) + finally: + executor.shutdown(wait=False, cancel_futures=True) + for agent in candidates: artifact_name = agent_ledger_artifact_name(request, agent) - if artifact_name not in artifact_cache: - response = dispatch_client.request( - [ - LEDGER_ARTIFACTS_ENDPOINT, - "-X", - "GET", - "-f", - f"name={artifact_name}", - "-f", - "per_page=100", - ] - ) - artifact_cache[artifact_name] = bool( - _artifact_records(response, expected_name=artifact_name) - ) - if artifact_cache[artifact_name]: + if artifact_cache.get(artifact_name): observed.add(agent) return frozenset(observed) diff --git a/scripts/ci/audit_central_required_workflows.py b/scripts/ci/audit_central_required_workflows.py old mode 100644 new mode 100755 index 4aa33929cd..cc27d5db7c --- a/scripts/ci/audit_central_required_workflows.py +++ b/scripts/ci/audit_central_required_workflows.py @@ -24,7 +24,7 @@ # while still being validated from an organization-admin ruleset payload. REQUIRED_EXCLUSION_PROBES = {".github", "noema"} REQUIRED_WORKFLOW_PATHS = ( - ".github/workflows/close-empty-pr.yml", + ".github/workflows/codeql-pr.yml", ".github/workflows/noema-review.yml", ".github/workflows/opencode-review.yml", ".github/workflows/pr-review-merge-scheduler.yml", @@ -129,8 +129,9 @@ def audit_ruleset(payload: dict[str, Any]) -> list[str]: workflows = workflows if isinstance(workflows, list) else [] workflows_by_path: dict[str, list[dict[str, Any]]] = {} - for workflow in workflows: + for index, workflow in enumerate(workflows): if not isinstance(workflow, dict) or not isinstance(workflow.get("path"), str): + errors.append(f"central required workflow entry {index} is malformed") continue workflows_by_path.setdefault(workflow["path"], []).append(workflow) @@ -151,6 +152,10 @@ def audit_ruleset(payload: dict[str, Any]) -> list[str]: f"{SOURCE_REPOSITORY_ID} at {SOURCE_REF}" ) + unexpected_paths = sorted(set(workflows_by_path) - set(REQUIRED_WORKFLOW_PATHS)) + for path in unexpected_paths: + errors.append(f"unexpected workflow present in required set: {path}") + review_rules = _typed_rules(payload, "pull_request") if len(review_rules) != 1: errors.append(f"expected one pull_request rule, found {len(review_rules)}") diff --git a/scripts/ci/audit_codeql_default_setup_rollout.py b/scripts/ci/audit_codeql_default_setup_rollout.py new file mode 100755 index 0000000000..6637601593 --- /dev/null +++ b/scripts/ci/audit_codeql_default_setup_rollout.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Classify CodeQL default-setup removal snapshots without mutating GitHub.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import re +import sys +from pathlib import Path +from typing import Any, TextIO +from urllib.parse import quote + +try: + from scripts.ci.organization_commercial_readiness_loop import ( + GitHubClient, + GitHubError, + ) +except ModuleNotFoundError: # Direct ``python scripts/ci/...`` execution. + from organization_commercial_readiness_loop import GitHubClient, GitHubError + +EXEMPT_REPOSITORIES = frozenset({".github", "noema", "IRT-bibliography-set"}) +SUCCESS = frozenset({"success", "neutral", "skipped"}) +PENDING = frozenset({"queued", "in_progress", "pending", "requested", "waiting"}) +RULESET_ID = 18156473 +CENTRAL_CODEQL_PATH = ".github/workflows/codeql-pr.yml" +CENTRAL_REPOSITORY_ID = 1274066402 +MAX_PAGES = 20 +MAX_WORKFLOW_BYTES = 1_048_576 + + +class EvidenceError(RuntimeError): + """Report missing or ambiguous live rollout evidence.""" + + +def _pages(client: Any, path: str, key: str | None = None) -> list[dict[str, Any]]: + """Read every bounded REST page and reject malformed evidence.""" + values: list[dict[str, Any]] = [] + separator = "" if path.endswith("?") else "&" if "?" in path else "?" + for page in range(1, MAX_PAGES + 1): + payload = client.request(f"{path}{separator}per_page=100&page={page}") + batch = payload.get(key) if key and isinstance(payload, dict) else payload + if not isinstance(batch, list) or not all(isinstance(item, dict) for item in batch): + raise EvidenceError(f"GitHub returned malformed pagination data for {path}") + values.extend(batch) + if len(batch) < 100: + return values + raise EvidenceError(f"GitHub pagination exceeded {MAX_PAGES} pages for {path}") + + +def _step_has_disabled_upload(lines: list[str], start: int) -> bool: + """Recognize only explicit, local neutralization of one CodeQL action step.""" + uses_indent = len(lines[start]) - len(lines[start].lstrip()) + block_start = start + for index in range(start - 1, -1, -1): + stripped = lines[index].lstrip() + indent = len(lines[index]) - len(stripped) + if stripped.startswith("-") and indent <= uses_indent: + block_start = index + break + step_indent = len(lines[block_start]) - len(lines[block_start].lstrip()) + block = [lines[block_start]] + for line in lines[block_start + 1 :]: + stripped = line.lstrip() + line_indent = len(line) - len(stripped) + if stripped.startswith("-") and line_indent <= step_indent: + break + block.append(line) + text = "\n".join(block) + return bool( + re.search(r"(?m)^\s*if:\s*(?:false|\$\{\{\s*false\s*\}\})\s*$", text) + or re.search(r"(?m)^\s*upload:\s*['\"]?never['\"]?\s*$", text) + ) + + +def _has_active_advanced_upload(source: str) -> bool: + """Conservatively detect an executable local CodeQL/SARIF upload step.""" + lines = source.splitlines() + for index, line in enumerate(lines): + if re.search( + r"uses:\s*github/codeql-action/(?:analyze|upload-sarif)@", line + ) and not _step_has_disabled_upload(lines, index): + return True + return False + + +def _active_advanced_uploader(client: Any, repository: str, head_sha: str) -> bool: + """Inspect active repository-owned workflow sources at the exact PR head.""" + workflows = _pages(client, f"/repos/{repository}/actions/workflows?", "workflows") + inspected_paths: set[str] = set() + for workflow in workflows: + path = str(workflow.get("path") or "") + if workflow.get("state") != "active" or not path.startswith(".github/workflows/"): + continue + if path in inspected_paths: + raise EvidenceError(f"active workflow identity is ambiguous: {path}") + inspected_paths.add(path) + encoded = quote(path, safe="/") + try: + source = client.request( + f"/repos/{repository}/contents/{encoded}?ref={head_sha}" + ) + except GitHubError as exc: + if "HTTP 404" in str(exc): + continue + raise EvidenceError(f"active workflow source lookup failed: {path}") from exc + if not isinstance(source, dict) or source.get("encoding") != "base64": + raise EvidenceError(f"active workflow source is unavailable: {path}") + size = source.get("size") + if not isinstance(size, int) or size < 0 or size > MAX_WORKFLOW_BYTES: + raise EvidenceError(f"active workflow source has invalid size: {path}") + try: + encoded_content = "".join(str(source.get("content") or "").split()) + decoded = base64.b64decode(encoded_content, validate=True).decode() + except (ValueError, UnicodeDecodeError) as exc: + raise EvidenceError(f"active workflow source is invalid: {path}") from exc + if len(decoded.encode()) != size: + raise EvidenceError(f"active workflow source size mismatch: {path}") + if _has_active_advanced_upload(decoded): + return True + return False + + +def collect_live_snapshot(client: Any, repository: str, pr_number: int) -> dict[str, Any]: + """Collect one exact-head rollout snapshot using read-only GitHub requests.""" + if not re.fullmatch(r"ContextualWisdomLab/[A-Za-z0-9_.-]+", repository): + raise EvidenceError("repository must belong to ContextualWisdomLab") + if pr_number < 1: + raise EvidenceError("pull request number must be positive") + + pull = client.request(f"/repos/{repository}/pulls/{pr_number}") + head_sha = str(((pull or {}).get("head") or {}).get("sha") or "") + if (pull or {}).get("state") != "open" or not re.fullmatch(r"[0-9a-f]{40}", head_sha): + raise EvidenceError("pull request is not open or has no valid exact head") + + inherited = _pages(client, f"/repos/{repository}/rulesets?includes_parents=true") + matches = [item for item in inherited if item.get("id") == RULESET_ID] + if len(matches) > 1: + raise EvidenceError("central ruleset evidence is ambiguous") + ruleset_applies = len(matches) == 1 + central_required = False + if ruleset_applies: + detail = client.request( + f"/repos/{repository}/rulesets/{RULESET_ID}?includes_parents=true" + ) + owners = [ + workflow + for rule in (detail or {}).get("rules", []) + if isinstance(rule, dict) and rule.get("type") == "workflows" + for workflow in (rule.get("parameters") or {}).get("workflows", []) + if isinstance(workflow, dict) + and workflow.get("path") == CENTRAL_CODEQL_PATH + and workflow.get("ref") == "refs/heads/main" + and workflow.get("repository_id") == CENTRAL_REPOSITORY_ID + ] + if len(owners) > 1: + raise EvidenceError("central CodeQL ruleset owner is ambiguous") + central_required = len(owners) == 1 + + name = repository.partition("/")[2] + if name in EXEMPT_REPOSITORIES: + latest_pull = client.request(f"/repos/{repository}/pulls/{pr_number}") + if str(((latest_pull or {}).get("head") or {}).get("sha") or "") != head_sha: + raise EvidenceError("pull request head changed during live evidence collection") + return {"name": name, "ruleset_applies": ruleset_applies} + + default_setup = client.request(f"/repos/{repository}/code-scanning/default-setup") + default_state = str((default_setup or {}).get("state") or "") + if default_state not in {"configured", "not-configured"}: + raise EvidenceError("default-setup state is unavailable") + + runs = _pages( + client, + f"/repos/{repository}/actions/runs?head_sha={head_sha}", + "workflow_runs", + ) + central_runs = [ + run + for run in runs + if run.get("path") == CENTRAL_CODEQL_PATH + and run.get("event") == "pull_request" + and run.get("head_sha") == head_sha + ] + if len(central_runs) != 1: + raise EvidenceError( + "exact-head central CodeQL run is missing or ambiguous" + ) + run = central_runs[0] + status = str(run.get("conclusion") or run.get("status") or "") + if not status: + raise EvidenceError("exact-head central CodeQL run has no status") + + result = { + "name": name, + "ruleset_applies": ruleset_applies, + "central_codeql_required": central_required, + "expected_head": head_sha, + "central_codeql_head": str(run.get("head_sha") or ""), + "central_codeql_status": status, + "default_setup_state": default_state, + "active_advanced_upload": _active_advanced_uploader( + client, repository, head_sha + ), + } + latest_pull = client.request(f"/repos/{repository}/pulls/{pr_number}") + if str(((latest_pull or {}).get("head") or {}).get("sha") or "") != head_sha: + raise EvidenceError("pull request head changed during live evidence collection") + return result + + +def classify(repository: dict[str, Any]) -> tuple[str, str]: + """Return a fail-closed rollout state and its operator-facing reason.""" + name = str(repository.get("name") or "") + ruleset_applies = repository.get("ruleset_applies") is True + if name in EXEMPT_REPOSITORIES: + if ruleset_applies: + return "BLOCK", "documented exception is unexpectedly covered by the central ruleset" + return "EXEMPT", "documented ruleset exception" + + if not ruleset_applies or repository.get("central_codeql_required") is not True: + return "BLOCK", "central CodeQL is not enforced by ruleset 18156473" + + expected_head = repository.get("expected_head") + observed_head = repository.get("central_codeql_head") + if not isinstance(expected_head, str) or len(expected_head) != 40 or observed_head != expected_head: + return "BLOCK", "central CodeQL evidence is absent or belongs to another head" + + central_status = repository.get("central_codeql_status") + default_state = repository.get("default_setup_state") + active_advanced_upload = repository.get("active_advanced_upload") is True + + if default_state == "configured": + if active_advanced_upload: + return "BLOCK", "default setup conflicts with an active advanced CodeQL uploader" + if central_status in SUCCESS: + return "READY_DISABLE", "exact-head central CodeQL passed; disable one repository only" + return "WAIT", "keep default setup until exact-head central CodeQL passes" + + if default_state != "not-configured": + return "BLOCK", "default-setup state is unavailable or unsupported" + if central_status in SUCCESS: + return "VERIFIED", "default setup is off and exact-head central CodeQL passed" + if central_status in PENDING: + return "WAIT", "default setup is off; wait for the exact-head central CodeQL verdict" + if active_advanced_upload: + return "BLOCK", "central CodeQL failed and default setup cannot coexist with the active uploader" + return "ROLLBACK", "central CodeQL failed; re-enable default setup before continuing" + + +def audit(repositories: list[dict[str, Any]]) -> list[tuple[str, str, str]]: + """Classify every repository snapshot in input order.""" + return [ + (str(repository.get("name") or ""), *classify(repository)) + for repository in repositories + ] + + +def load_payload(path: Path | None, stdin: TextIO) -> list[dict[str, Any]]: + """Load a repository snapshot array from a file or standard input.""" + if path: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + else: + payload = json.load(stdin) + if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): + raise ValueError("repository snapshot root must be an array of objects") + return payload + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse CLI arguments for either the file-payload or live-collection mode.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("snapshots_json", nargs="?", type=Path) + parser.add_argument("--repository") + parser.add_argument("--pr", type=int) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Audit CodeQL rollout state from file or live snapshots and print verdicts.""" + args = parse_args(argv) + try: + live_mode = args.repository is not None or args.pr is not None + if live_mode: + if args.snapshots_json or not args.repository or args.pr is None: + raise ValueError("live mode requires --repository and --pr only") + repositories = [ + collect_live_snapshot( + GitHubClient.from_environment(), args.repository, args.pr + ) + ] + else: + repositories = load_payload(args.snapshots_json, sys.stdin) + results = audit(repositories) + except (OSError, ValueError, json.JSONDecodeError, EvidenceError, GitHubError) as exc: + print(f"ERROR: unable to load CodeQL rollout snapshots: {exc}", file=sys.stderr) + return 2 + for name, state, reason in results: + print(f"CODEQL_ROLLOUT repository={name} state={state} reason={reason}") + return 0 if all(state in {"EXEMPT", "VERIFIED"} for _, state, _ in results) else 1 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/audit_org_codeql_coverage.py b/scripts/ci/audit_org_codeql_coverage.py new file mode 100644 index 0000000000..bdbc835491 --- /dev/null +++ b/scripts/ci/audit_org_codeql_coverage.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Audit every ContextualWisdomLab organization repository for real CodeQL coverage. + +This is a permanent, read-only, scheduled counterpart to the one-time manual +remediation performed on 2026-09-03: 23 organization repositories had zero +CodeQL coverage from any source (no repository-local workflow, no GitHub +native ``code-scanning/default-setup``) and were fixed by hand. This script +detects that same gap automatically going forward -- e.g. a newly created +repository, or an existing repository whose default-setup is disabled -- so +the gap cannot silently recur. It only reports drift; it never mutates +anything. Remediation (enabling default-setup, or adding a workflow) is a +separate, human/agent-directed action. +""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone +import json +from pathlib import Path +import sys +from typing import Any, TextIO + + +# Live-verified (2026-09-03) via `gh api +# repos/ContextualWisdomLab/wardnet/code-scanning/default-setup --jq +# '.schedule'` -> "weekly": GitHub's native code-scanning/default-setup -- +# the mechanism most organization repositories rely on for CodeQL coverage, +# as opposed to a locally-triggered push/pull_request workflow, which would +# produce analysis records far more often than weekly and never approach +# this threshold in practice -- runs on a 7-day cadence. A repository +# relying on default-setup will therefore realistically go up to ~7 days +# between analyses in the normal case. +# +# 35 days is deliberately 5x that observed 7-day interval: a safety margin +# against a single missed or delayed scheduled run (a holiday, a GitHub +# platform incident, or this organization's own well-documented Actions +# queue congestion under hosted-runner saturation -- see +# docs/doctoring/actions-queue-saturation-hourly-sweep.md, a real, observed +# risk here, not hypothetical), not an unexplained rule of thumb. +CODEQL_ANALYSIS_FRESHNESS_DAYS = 35 + + +def _is_analysis_fresh_and_successful( + latest_codeql_analysis: Any, now: datetime +) -> bool: + """Return True when ``latest_codeql_analysis`` is recent and error-free. + + A malformed or unparseable ``created_at`` -- or a missing/non-dict record + -- fails closed (returns False) rather than raising, so one bad record + cannot crash the whole audit run. + """ + if not isinstance(latest_codeql_analysis, dict): + return False + if latest_codeql_analysis.get("error"): + return False + created_at = latest_codeql_analysis.get("created_at") + if not isinstance(created_at, str): + return False + try: + parsed = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + except ValueError: + return False + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed >= now - timedelta(days=CODEQL_ANALYSIS_FRESHNESS_DAYS) + + +def _default_setup_scans_a_language(repository: dict[str, Any]) -> bool: + """Return True when default-setup is configured AND has languages enabled. + + ``state == "configured"`` alone is not coverage. Measured 2026-09-07: + ``life-os``, ``aFIPC`` and ``inkspan`` all report ``configured`` with an + **empty** ``languages`` list and no ``schedule``; ``life-os`` has zero CodeQL + analyses of any language as a result, while still satisfying the + configured-state check this function replaces. A default setup with nothing + enabled is a commitment to scan nothing. + + A missing ``default_setup_languages`` key fails closed rather than falling + back to the state alone, which would silently restore that gap. The audit + workflow collects the field in the same change that introduced this check, + so the key is absent only when the payload predates them both. + """ + if repository.get("default_setup_state") != "configured": + return False + languages = repository.get("default_setup_languages") + return isinstance(languages, list) and bool(languages) + + +def auditable_repositories( + repositories: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Return the repositories this audit actually examines. + + Archived repositories are excluded: they cannot run workflows or code + scanning, so a lack of coverage there is not a product gap. Counting them + as examined is what let ``main`` report success over an empty subject set. + """ + return [ + repository + for repository in repositories + if not repository.get("archived") + ] + + +def repositories_without_codeql( + repositories: list[dict[str, Any]], now: datetime | None = None +) -> list[dict[str, Any]]: + """Return non-archived repositories without current CodeQL coverage. + + A repository is flagged only when it is not archived AND both coverage + signals are absent: ``default_setup_state`` is not ``"configured"``, and + ``latest_codeql_analysis`` is not a fresh (within + ``CODEQL_ANALYSIS_FRESHNESS_DAYS``), error-free analysis record. Archived + repositories are skipped entirely -- they cannot run workflows or code + scanning, so a lack of coverage there is not a real product gap (matching + the exclusion of ``trivy-sarif-repro`` from today's manual remediation). + """ + current = now or datetime.now(timezone.utc) + uncovered: list[dict[str, Any]] = [] + for repository in auditable_repositories(repositories): + # "configured" is GitHub's own forward-looking commitment to run + # CodeQL going forward (like a scheduled cron guarantee), not a + # one-time historical scan that can go stale -- so it does not need + # the same freshness check as latest_codeql_analysis below. Do not + # "fix" this into requiring a completed scan. It does need the + # commitment to cover at least one language: see + # _default_setup_scans_a_language. + has_default_setup = _default_setup_scans_a_language(repository) + has_fresh_analysis = _is_analysis_fresh_and_successful( + repository.get("latest_codeql_analysis"), current + ) + if not has_default_setup and not has_fresh_analysis: + uncovered.append(repository) + return uncovered + + +def _coverage_gap_reason(repository: dict[str, Any]) -> str: + """Return the gap description that tells the operator what to change. + + "Default setup is on but scans nothing" and "there is no coverage at all" + need different fixes -- enable languages on the existing setup, versus set + coverage up -- so they are reported as different sentences. + """ + if repository.get("default_setup_state") == "configured": + return ( + f"{repository.get('name')} has CodeQL default-setup configured with no " + "languages enabled, so it scans nothing and produces no analyses" + ) + return ( + f"{repository.get('name')} has no CodeQL coverage from any source " + "(no default-setup, no recent analysis)" + ) + + +def audit_codeql_coverage( + repositories: list[dict[str, Any]], now: datetime | None = None +) -> list[str]: + """Return one human-readable error per repository with zero CodeQL coverage.""" + return [ + _coverage_gap_reason(repository) + for repository in repositories_without_codeql(repositories, now) + ] + + +def load_payload(path: Path | None, stdin: TextIO) -> list[dict[str, Any]]: + """Load the per-repository JSON array from ``path`` or standard input.""" + if path is None: + payload = json.load(stdin) + else: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + raise ValueError("repository JSON root must be a list") + return payload + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse the optional repository JSON array path.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repositories_json", nargs="?", type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Audit the organization's CodeQL coverage and print every gap found.""" + args = parse_args(argv) + try: + repositories = load_payload(args.repositories_json, sys.stdin) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"ERROR: unable to load repository JSON: {exc}", file=sys.stderr) + return 2 + + audited = auditable_repositories(repositories) + if not audited: + # An audit that examined nothing is not a clean organization, and + # "PASS: all 0 repositories have real CodeQL coverage" reads as + # success. The count that matters is what was examined, not what was + # supplied: an empty payload and a payload of nothing but archived + # repositories both reach zero subjects, and only the first was caught + # when this guard counted `repositories`. The calling workflow refuses + # an enumeration missing its known-private sentinel repositories, but + # the script is directly runnable against a JSON path or stdin, so the + # guard has to live here too. + print( + f"ERROR: this run audited nothing " + f"(0 of {len(repositories)} repositories were eligible)", + file=sys.stderr, + ) + return 2 + + errors = audit_codeql_coverage(repositories) + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + print( + f"FAIL: {len(errors)} repositories have no CodeQL coverage", + file=sys.stderr, + ) + return 1 + + print(f"PASS: all {len(audited)} repositories have real CodeQL coverage") + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through main() + raise SystemExit(main()) diff --git a/scripts/ci/bootstrap_codeql_pull_requests.py b/scripts/ci/bootstrap_codeql_pull_requests.py new file mode 100644 index 0000000000..90b1c3abcb --- /dev/null +++ b/scripts/ci/bootstrap_codeql_pull_requests.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Create one idempotent OpenCode-owned CodeQL setup PR for uncovered repositories.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from typing import Any, Mapping, TextIO + +from scripts.ci.audit_org_codeql_coverage import repositories_without_codeql + + +ORGANIZATION = "ContextualWisdomLab" +BOOTSTRAP_BRANCH = "opencode/codeql-setup" +WORKFLOW_PATH = ".github/workflows/codeql.yml" + + +class GitHubError(RuntimeError): + """Report a bounded GitHub API or repository-state failure.""" + + +class GitHubClient: + """Use the GitHub CLI with an OpenCode installation token.""" + + def __init__(self, token: str, *, timeout_seconds: int = 60) -> None: + """Store a non-empty opaque token without format or length assumptions.""" + if not token: + raise GitHubError("OPENCODE_APP_TOKEN is required") + self._token = token + self._timeout_seconds = timeout_seconds + + @classmethod + def from_environment(cls, environ: Mapping[str, str] | None = None) -> GitHubClient: + """Build a client from the explicit OpenCode installation token.""" + values = os.environ if environ is None else environ + return cls(str(values.get("OPENCODE_APP_TOKEN") or "").strip()) + + def request(self, path: str, *, method: str = "GET", payload: Any = None) -> Any: + """Call one REST endpoint and decode its JSON response.""" + args = ["gh", "api", path] + if method != "GET": + args.extend(["--method", method]) + input_text = None + if payload is not None: + args.extend(["--input", "-"]) + input_text = json.dumps(payload, separators=(",", ":")) + try: + result = subprocess.run( + args, + input=input_text, + capture_output=True, + text=True, + timeout=self._timeout_seconds, + env={**os.environ, "GH_TOKEN": self._token}, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise GitHubError(f"GitHub API transport failed: {type(exc).__name__}") from exc + if result.returncode: + diagnostic = (result.stderr or result.stdout or "request failed")[-600:] + diagnostic = diagnostic.replace(self._token, "[REDACTED]") + raise GitHubError(f"GitHub API {method} {path} failed: {diagnostic}") + if not result.stdout.strip(): + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise GitHubError(f"GitHub API returned invalid JSON for {path}") from exc + + +def render_workflow(default_branch: str) -> str: + """Render a no-autobuild CodeQL workflow that redetects stacks on every run.""" + if not re.fullmatch(r"[A-Za-z0-9._/-]+", default_branch) or ".." in default_branch: + raise ValueError("default branch is not safe for workflow generation") + return f'''name: CodeQL + +on: + push: + branches: [{json.dumps(default_branch)}] + schedule: + - cron: "23 4 * * 3" + +concurrency: + group: codeql-${{{{ github.repository }}}}-${{{{ github.event_name == 'push' && github.ref || github.event_name }}}} + cancel-in-progress: true + +permissions: + contents: read + security-events: write + +jobs: + detect-languages: + runs-on: ubuntu-latest + outputs: + matrix: ${{{{ steps.detect.outputs.matrix }}}} + steps: + - id: detect + env: + GH_TOKEN: ${{{{ github.token }}}} + run: | + set -euo pipefail + languages="$(gh api "repos/${{{{ github.repository }}}}/languages")" + jq -cn --argjson languages "$languages" '{{ + include: ([{{language:"actions","build-mode":"none"}}] + [ + ($languages | keys[]) as $name | + {{ + language: ({{ + "C":"c-cpp","C++":"c-cpp","C#":"csharp","Go":"go", + "Java":"java-kotlin","Kotlin":"java-kotlin", + "JavaScript":"javascript-typescript","TypeScript":"javascript-typescript", + "Python":"python","Ruby":"ruby","Rust":"rust","Swift":"swift" + }}[$name]), + "build-mode":"none" + }} | select(.language != null) + ] | unique_by(.language)) + }}' > matrix.json + echo "matrix=$(cat matrix.json)" >> "$GITHUB_OUTPUT" + + analyze: + name: Analyze (${{{{ matrix.language }}}}) + needs: detect-languages + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{{{ fromJSON(needs.detect-languages.outputs.matrix) }}}} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + with: + languages: ${{{{ matrix.language }}}} + build-mode: ${{{{ matrix.build-mode }}}} + - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 +''' + + +def bootstrap_repository(client: GitHubClient, repository: str) -> str: + """Create the setup branch, workflow commit, and PR, or return a skip reason.""" + full_name = f"{ORGANIZATION}/{repository}" + metadata = client.request(f"repos/{full_name}") or {} + default_branch = str(metadata.get("default_branch") or "") + if not default_branch: + return "pending-empty-repository" + base = client.request(f"repos/{full_name}/git/ref/heads/{default_branch}") or {} + base_sha = str(((base.get("object") or {}).get("sha")) or "") + if not re.fullmatch(r"[0-9a-f]{40}", base_sha): + raise GitHubError(f"{full_name} returned an invalid default-branch SHA") + + existing = client.request( + f"repos/{full_name}/pulls?state=open&head={ORGANIZATION}:{BOOTSTRAP_BRANCH}" + ) or [] + if existing: + return "open-pr-exists" + try: + client.request(f"repos/{full_name}/git/ref/heads/{BOOTSTRAP_BRANCH}") + except GitHubError as exc: + if "HTTP 404" not in str(exc): + raise + else: + raise GitHubError(f"{full_name} has an unmanaged {BOOTSTRAP_BRANCH} branch") + + client.request( + f"repos/{full_name}/git/refs", + method="POST", + payload={"ref": f"refs/heads/{BOOTSTRAP_BRANCH}", "sha": base_sha}, + ) + content = render_workflow(default_branch) + client.request( + f"repos/{full_name}/contents/{WORKFLOW_PATH}", + method="PUT", + payload={ + "message": "ci(codeql): add adaptive CodeQL analysis", + "content": base64.b64encode(content.encode()).decode(), + "branch": BOOTSTRAP_BRANCH, + }, + ) + pull = client.request( + f"repos/{full_name}/pulls", + method="POST", + payload={ + "title": "ci(codeql): add adaptive CodeQL analysis", + "head": BOOTSTRAP_BRANCH, + "base": default_branch, + "body": ( + "OpenCode Agent detected that this repository has no active CodeQL coverage. " + "This SHA-pinned workflow redetects supported languages on every run and never " + "executes repository build scripts." + ), + }, + ) or {} + return f"created-pr-{pull.get('number', 'unknown')}" + + +def load_payload(path: Path, stdin: TextIO) -> list[dict[str, Any]]: + """Load and validate the shared coverage payload.""" + if path == Path("-"): + payload = json.load(stdin) + else: + with path.open(encoding="utf-8") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + raise ValueError("repository JSON root must be a list") + return payload + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse the coverage payload path.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repositories_json", type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Bootstrap every uncovered repository and fail closed on any write failure.""" + args = parse_args(argv) + try: + repositories = load_payload(args.repositories_json, sys.stdin) + client = GitHubClient.from_environment() + for repository in repositories_without_codeql(repositories): + name = str(repository.get("name") or "") + if not re.fullmatch(r"[A-Za-z0-9_.-]+", name): + raise GitHubError("coverage payload contained an invalid repository name") + print(f"CODEQL_BOOTSTRAP repository={name} result={bootstrap_repository(client, name)}") + except (OSError, ValueError, json.JSONDecodeError, GitHubError) as exc: + print(f"ERROR: CodeQL bootstrap failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/ci/codeql_sarif_gate.py b/scripts/ci/codeql_sarif_gate.py new file mode 100644 index 0000000000..3b232c3bdb --- /dev/null +++ b/scripts/ci/codeql_sarif_gate.py @@ -0,0 +1,135 @@ +"""Fail closed on unsuppressed Medium+ CodeQL SARIF findings. + +Extracted from the duplicated inline Python previously embedded in both the +``analyze-head`` and ``analyze-merge`` jobs of ``codeql-pr.yml`` so the same +severity gate can be reused by the dispatch-based rewrite proposed in +ContextualWisdomLab/.github#1772 without a third copy of this logic. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, NamedTuple + +MEDIUM_PLUS_SCORE = 4.0 +SEVERITY_LEVELS = {"error", "warning"} + + +class Finding(NamedTuple): + """One unsuppressed Medium+ CodeQL SARIF result.""" + + rule_id: str + score: float | None + level: str + path: str + line: int + message: str + + +def iter_sarif_files(root: Path) -> list[Path]: + """Return every ``*.sarif`` file under ``root``, sorted for stable output.""" + return sorted(root.rglob("*.sarif")) + + +def _rule_for_result(result: dict[str, Any], rules: list[Any]) -> dict[str, Any]: + """Resolve the SARIF rule definition referenced by a result.""" + rules_by_id = { + str(rule.get("id") or ""): rule for rule in rules if isinstance(rule, dict) + } + rule = rules_by_id.get(str(result.get("ruleId") or ""), {}) + if rule: + return rule + rule_index = result.get("ruleIndex") + if isinstance(rule_index, int) and 0 <= rule_index < len(rules): + candidate = rules[rule_index] + if isinstance(candidate, dict): + return candidate + return {} + + +def _is_medium_plus(score: float | None, level: str, security_rule: bool) -> bool: + """A result gates the PR if it scores >=4.0, or is an unscored security finding.""" + if score is not None: + return score >= MEDIUM_PLUS_SCORE + return security_rule and level in SEVERITY_LEVELS + + +def _finding_from_result(result: dict[str, Any], rules: list[Any]) -> Finding | None: + """Build a `Finding` for one SARIF result, or None if it doesn't gate the PR.""" + if not isinstance(result, dict) or result.get("suppressions"): + return None + rule = _rule_for_result(result, rules) + result_properties = result.get("properties") or {} + rule_properties = rule.get("properties") or {} + raw_score = result_properties.get("security-severity", rule_properties.get("security-severity")) + try: + score = float(raw_score) + except (TypeError, ValueError): + score = None + level = str(result.get("level") or (rule.get("defaultConfiguration") or {}).get("level") or "none").lower() + tags = {str(tag).lower() for tag in rule_properties.get("tags") or []} + security_rule = "security" in tags or any(tag.startswith("external/cwe/") for tag in tags) + if not _is_medium_plus(score, level, security_rule): + return None + physical = ((result.get("locations") or [{}])[0].get("physicalLocation") or {}) + artifact = (physical.get("artifactLocation") or {}).get("uri") or "unknown" + line = (physical.get("region") or {}).get("startLine") or 0 + message = str((result.get("message") or {}).get("text") or "no message").replace("\n", " ") + return Finding( + rule_id=str(result.get("ruleId") or rule.get("id") or "unknown"), + score=score, + level=level, + path=artifact, + line=line, + message=message, + ) + + +def gather_findings(root: Path) -> tuple[list[Finding], int, int]: + """Scan every SARIF file under `root`; return (findings, total_results, file_count).""" + paths = iter_sarif_files(root) + findings: list[Finding] = [] + total_results = 0 + for path in paths: + payload = json.loads(path.read_text(encoding="utf-8")) + for run in payload.get("runs") or []: + rules = ((run.get("tool") or {}).get("driver") or {}).get("rules") or [] + for result in run.get("results") or []: + if not isinstance(result, dict): + continue + total_results += 1 + finding = _finding_from_result(result, rules) + if finding is not None: + findings.append(finding) + return findings, total_results, len(paths) + + +def format_finding(finding: Finding) -> str: + """Render one finding as a single grep-able log line.""" + severity = f"security-severity={finding.score:g}" if finding.score is not None else f"level={finding.level}" + return f"CODEQL_FINDING rule={finding.rule_id} {severity} path={finding.path} line={finding.line} message={finding.message}" + + +def main(argv: list[str] | None = None) -> int: + """Gate on a directory of CodeQL SARIF output; print evidence and fail closed.""" + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 1: + raise SystemExit("usage: codeql_sarif_gate.py SARIF_DIR") + + root = Path(args[0]) + findings, total_results, file_count = gather_findings(root) + if file_count == 0: + raise SystemExit(f"CodeQL produced no SARIF under {root}; inspect the analysis log above.") + + print(f"CODEQL_SARIF files={file_count} results={total_results} medium_plus={len(findings)}") + for finding in findings: + print(format_finding(finding)) + if findings: + raise SystemExit(f"CodeQL found {len(findings)} unsuppressed Medium+ security result(s).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/contextual_orchestrator_review_launcher.py b/scripts/ci/contextual_orchestrator_review_launcher.py index 2e56809639..e8c462abcc 100644 --- a/scripts/ci/contextual_orchestrator_review_launcher.py +++ b/scripts/ci/contextual_orchestrator_review_launcher.py @@ -22,14 +22,20 @@ from __future__ import annotations import argparse +import copy +import dataclasses import json +import logging import os import re import sys from pathlib import Path -from typing import Any +from typing import Any, Callable -from scripts.ci.contextual_orchestrator_review_policy import FREE_POOL_CREDENTIAL_NAMES +from scripts.ci.contextual_orchestrator_review_policy import ( + FREE_POOL_CREDENTIAL_NAMES, + provider_account, +) # The vendored server's generic 64 KiB default is intentionally conservative. @@ -42,8 +48,60 @@ # Provider-neutral sampling: several modern endpoints reject non-default # temperatures, while 1.0 is the OpenAI-compatible default. REVIEW_TEMPERATURE = 1.0 -REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 12 -REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 8 +# Lazy fill (ADR-0029): the catalog is a *candidate* list, probed in its +# tier-then-round-robin order until REVIEW_PREFLIGHT_TARGET_READY routes are +# ready or REVIEW_PREFLIGHT_MAX_PROBES probes are spent, whichever comes first. +# A permanently dead candidate (NIM lists gemma-3-12b/4b but answers 404 on +# every run) then costs one probe instead of a served slot, and a healthy hour +# stops early instead of always probing every candidate. MAX_TOTAL_ROUTES is +# the two-stage total (auto pool: 16 free, up to 8 priced; the production +# ``free`` pool lists all 24). A silent candidate's probe costs up to one +# transport timeout (19 probes took 805 s in one artifact), so MAX_PROBES +# bounds preflight wall time as well as request count. Candidates past the +# probe cap are reached only when the account rule below sets earlier ones +# aside, and the report separates ``skipped_count`` (set aside, never probed) +# from the unreached tail so the evidence stays readable. +REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES = 24 +REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT = 16 +REVIEW_PREFLIGHT_TARGET_READY = 8 +REVIEW_PREFLIGHT_MAX_PROBES = 16 +# Once one credential account has answered 429 to this many probes in a row, +# its remaining candidates are set aside so the walk reaches the other +# accounts' next candidates first: under the real 2026-09-06 candidate order +# (jan's table on #1949) the round-robin would otherwise spend five of sixteen +# probes on an account whose every free route answered 429, and the readiness +# target was unreachable; setting them aside lets the same sixteen probes +# reach both keys' llama routes (catalog position 17, ready in eight of the +# fourteen merged-rule artifacts of 2026-09-06 and reached only this way). +# +# But the rule must not END the walk. When every account is set aside the walk +# stops with most of its probe budget unspent and the stage fails closed -- +# and because deferral needs one ready route (#1947), nothing is served +# either. Measured that day: `.github` run 34016207820 sent six probes across +# all three accounts between 07:49:35.111Z and 07:49:35.767Z, every one +# refused 429, and gave up with ten probes unspent; five runs between 07:24Z +# and 08:05Z read probed 6 / skipped 18 / ready 0. Because the walk is a +# round-robin, "two consecutive 429s" on one account is two requests about +# 310 ms apart (nvidia_nim at .111 and .422). +# +# A refusal is not a verdict on the account. Run 34016093772 was inside its +# own preflight during that burst, and its llama probes on the same two NVIDIA +# keys answered ready at 07:50:58.7 and 07:50:59.0 -- 84 s after those keys +# refused 429. Whether the unspent probes would find a ready route *inside* a +# burst is still unmeasured; that is what `retry_after_s` is for. What is +# certain is that failing closed with two thirds of the budget in hand is +# indefensible, and the cost of spending it is bounded by the probe count, not +# a clock: a refused probe costs about 120 ms, a silent one up to the 90 s +# receive timeout, and the postponed tail contains both (google/gemma-4-31b-it +# answered TimeoutError in 15 of the 19 probes that reached it). See ADR-0029's +# amendment for the full cost table. +# +# So a set-aside candidate is postponed, not banned: once the first pass ends +# with the target unmet and probes left, the postponed candidates are probed +# in catalog order until the budget is spent. Probed 429 routes are still +# deferred (#1947); a candidate the budget never reaches is neither probed nor +# served. +REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429 = 2 # ADR-0005: a single fixed max_tokens cannot fit every model in a heterogeneous # pool -- some spend internal reasoning tokens before visible content and need # more, others have a real completion ceiling a large budget would exceed. The @@ -62,6 +120,28 @@ # Shared cap on how many candidates in one preflight run may use the # escalation retry above. It bounds request count, never model response time. REVIEW_PREFLIGHT_MAX_ESCALATIONS = 4 +# Probe outcomes the serving gateway itself treats as transient -- it retries +# the same route and then fails over across exactly these statuses +# (contextual_orchestrator.orchestrator.TRANSIENT_HTTP_STATUS at the vendored +# pin; provider_errors.PROVIDER_STATUS_SURFACES marks 429 retryable). A route +# that answered one of them to the 16-token probe is not known to be dead; it +# was rate-limited or unlucky in the second the probe ran, very often because +# the probe itself spent the per-key budget. Discarding it left the serving +# set with nothing to fail over to: on 2026-09-05 a noema-review preflight +# rejected 11 of 12 routes -- six of them with 429 -- served the one ready +# route for 542 s and returned 502. Such routes are kept as *deferred*, ranked +# after every ready route, so failover has somewhere to go. Only a route that +# *answered* with one of these statuses qualifies (a probe that timed out +# records no http_status and stays rejected), so deferral never admits, on the +# strength of a probe that already showed it, the silent route whose serving +# request would spend the gateway's full retry budget in 90 s timeouts. Keep +# this set in sync with the vendored orchestrator's; a status the gateway +# would not retry must not be deferred. +REVIEW_PREFLIGHT_DEFERRABLE_HTTP_STATUS = frozenset({408, 409, 425, 429, 500, 502, 503, 504, 529}) +# Subtracted from a deferred route's catalog priority so the orchestrator's +# ranking (higher priority first; catalog priorities are 0..-11) never places a +# deferred route ahead of a ready one. +REVIEW_PREFLIGHT_DEFERRED_PRIORITY_PENALTY = 1000 class ReviewPreflightError(RuntimeError): @@ -214,6 +294,46 @@ def _safe_http_status(exc: Exception) -> int | None: return None +def _safe_retry_after_seconds(exc: Exception) -> int | None: + """Return the response's ``Retry-After`` delay in whole seconds, if it sent one. + + Recorded so the evidence can answer a question this codebase cannot + answer today: when a preflight probe is refused with 429, do the + providers say how long the refusal lasts? The 2026-09-06 artifacts show + every probe of a burst refused inside a second (`.github` 34016207820 and + four sibling boots), with nothing in the evidence about how long the + refusal window actually was. Only the delta-seconds + form is read; the HTTP-date form and anything out of range record + nothing, because a wrong number here would be worse than no number. + This is evidence only -- no code waits on it (ADR-0003). + + Args: + exc: The exception a probe attempt raised. + + Returns: + The delay in seconds, or ``None`` when the response carried no + usable ``Retry-After`` header. + """ + headers = getattr(exc, "headers", None) + get_header = getattr(headers, "get", None) + if not callable(get_header): + return None + try: + raw = get_header("Retry-After") + except Exception: # noqa: BLE001 - a hostile header mapping is not evidence + return None + # ``isdecimal`` rather than ``isdigit``: a provider controls this header, + # and ``"²".isdigit()`` is True while ``int("²")`` raises. This + # runs inside the probe walk's exception handler, so a ValueError here + # would escape ``_preflight_review_agents`` -- whose callers catch only + # ``ReviewPreflightError`` -- and kill the boot before any evidence file + # is written. Every ``isdecimal`` string is accepted by ``int``. + if not isinstance(raw, str) or not raw.strip().isdecimal(): + return None + seconds = int(raw.strip()) + return seconds if 0 <= seconds <= 86400 else None + + def _response_finish_reason(response: object) -> str | None: """Return a bounded ``finish_reason`` string from an OpenAI-compatible response. @@ -275,10 +395,29 @@ def _record_provider_exception(row: dict[str, object], exc: Exception) -> None: http_status = _safe_http_status(exc) if http_status is not None: row["http_status"] = http_status + retry_after = _safe_retry_after_seconds(exc) + if retry_after is not None: + row["retry_after_s"] = retry_after row.pop("finish_reason", None) row.pop("reasoning_without_content", None) +def _demote_agent(agent: object, penalty: int) -> object: + """Return a copy of ``agent`` whose ``priority`` is lowered by ``penalty``. + + Serving agents are frozen ``ModelAgent`` dataclasses, so the copy goes + through :func:`dataclasses.replace`; the plain objects tests use are + shallow-copied and assigned. A missing ``priority`` counts as 0, matching + the dataclass default. + """ + priority = int(getattr(agent, "priority", 0)) - penalty + if dataclasses.is_dataclass(agent) and not isinstance(agent, type): + return dataclasses.replace(agent, priority=priority) + demoted = copy.copy(agent) + demoted.priority = priority + return demoted + + def _response_has_reasoning_without_content(response: object) -> bool: """Return whether a response matches the vendored "reasoning, no content" signature. @@ -368,6 +507,20 @@ def _preflight_review_agents( response, both fields are absent entirely (there is no response to describe) rather than silently retaining the base attempt's values. + Candidates are probed lazily in catalog order (ADR-0029): probing stops + once ``REVIEW_PREFLIGHT_TARGET_READY`` routes are ready or + ``REVIEW_PREFLIGHT_MAX_PROBES`` probes have been spent, so a dead + candidate costs one probe rather than a served slot and a healthy pool is + not probed to exhaustion. An account that has answered 429 to + ``REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429`` consecutive probes has its + remaining candidates postponed behind the other accounts' candidates; + once the first pass ends with the target unmet and budget left, the + postponed candidates are probed in catalog order (a 429 is an answer + about the instant, not the account). Unprobed candidates get no + ``routes`` row; ``skipped_count`` counts the postponed candidates the + budget never reached, ``postponed_probed_count`` the ones it did, and + ``candidate_count - probed_count - skipped_count`` the unreached tail. + Args: agents: Selected zero-cost model agents. client: Vendored ``ModelClient``-compatible transport. @@ -386,7 +539,41 @@ def _preflight_review_agents( """ viable: list[object] = [] routes: list[dict[str, object]] = [] - for agent in agents: + consecutive_429: dict[str, int] = {} + # Candidates the account rule set aside in the first pass, in catalog + # order. They are probed in a second pass while budget is left and the + # target is unmet; the ones that pass never reaches are the skipped ones. + postponed: list[object] = [] + postponed_probed = 0 + # One entry per probe, in probe order: ``routes[i]`` describes + # ``probed[i]``. A postponed candidate joins both only when its probe + # runs, so the deferral pass below must pair rows with this list, not + # with ``agents``. + probed: list[object] = [] + walk = iter(agents) + second_pass = False + # A dedicated sentinel, not ``None``: ``None`` is a legal element of a + # candidate list and would silently truncate the walk. + exhausted = object() + while True: + if len(viable) >= REVIEW_PREFLIGHT_TARGET_READY or len(routes) >= REVIEW_PREFLIGHT_MAX_PROBES: + break + agent = next(walk, exhausted) + if agent is exhausted: + if second_pass or not postponed: + break + walk = iter(postponed) + second_pass = True + continue + account = provider_account(str(getattr(agent, "provider_name", "") or "unknown")) + if second_pass: + postponed_probed += 1 + elif consecutive_429.get(account, 0) >= REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429: + postponed.append(agent) + continue + # Cleared here; only a 429 answer below restores it, incremented. + streak_429 = consecutive_429.pop(account, 0) + probed.append(agent) row: dict[str, object] = { "agent_id": str(getattr(agent, "id", "")), "provider": str(getattr(agent, "provider_name", "") or "unknown"), @@ -407,6 +594,8 @@ def _preflight_review_agents( response = client.proxy_send_once(agent, "chat/completions", base_payload) except Exception as exc: # noqa: BLE001 - sanitize at the provider boundary _record_provider_exception(row, exc) + if row.get("http_status") == 429: + consecutive_429[account] = streak_429 + 1 routes.append(row) continue if _chat_response_has_text(response): @@ -456,11 +645,27 @@ def _preflight_review_agents( # a specific policy without real telemetry on which candidates # actually need escalation would itself be the kind of unjustified # heuristic this design rejects elsewhere. - if not budget_signature or escalations_used >= REVIEW_PREFLIGHT_MAX_ESCALATIONS: + # The second pass never draws on the shared escalation budget. That + # budget is one counter for the whole run, spent in catalog order and + # carried into the priced fallback stage (#1458). Candidates in the + # second pass are ones the account rule had set aside and the previous + # design never probed at all, so letting them claim escalations would + # take them from stages that had them before: measured on a two-stage + # run where every primary candidate on one account answered 429, the + # priced fallback candidate that needs its escalation is denied one and + # the run stops serving a route it used to serve. + if ( + not budget_signature + or second_pass + or escalations_used >= REVIEW_PREFLIGHT_MAX_ESCALATIONS + ): row["status"] = "rejected" - row["error_type"] = ( - "invalid_chat_response" if not budget_signature else "escalation_budget_exhausted" - ) + if not budget_signature: + row["error_type"] = "invalid_chat_response" + elif second_pass: + row["error_type"] = "escalation_reserved_for_first_pass" + else: + row["error_type"] = "escalation_budget_exhausted" routes.append(row) continue escalations_used += 1 @@ -507,11 +712,36 @@ def _preflight_review_agents( ) routes.append(row) + # Deferral pass: a route rejected with a status the serving gateway would + # retry and fail over across is kept behind the ready routes instead of + # being discarded -- but only once at least one route is ready. With no + # ready route the run still fails this stage exactly as before, so + # _preflight_with_fallback's "priced catalog only after every primary + # route rejects" contract (ADR-0005) is unchanged. ``routes`` holds one + # row per *probed* agent in probe order (every branch above appends once), + # and ``probed`` the matching agents -- a postponed candidate is in both + # once its second-pass probe has run, and in neither otherwise. + deferred: list[object] = [] + if viable: + for agent, row in zip(probed, routes): + if ( + row.get("status") == "rejected" + and row.get("http_status") in REVIEW_PREFLIGHT_DEFERRABLE_HTTP_STATUS + ): + row["status"] = "deferred" + deferred.append(_demote_agent(agent, REVIEW_PREFLIGHT_DEFERRED_PRIORITY_PENALTY)) report: dict[str, object] = { "contract": "strix-plain-chat-preflight-v2", - "probed_count": len(agents), + "candidate_count": len(agents), + "probed_count": len(routes), "ready_count": len(viable), - "rejected_count": len(agents) - len(viable), + "deferred_count": len(deferred), + "rejected_count": len(routes) - len(viable) - len(deferred), + "skipped_count": len(postponed) - postponed_probed, + "postponed_probed_count": postponed_probed, + "target_ready": REVIEW_PREFLIGHT_TARGET_READY, + "probe_budget": REVIEW_PREFLIGHT_MAX_PROBES, + "account_skip_after_429": REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429, "escalations_used": escalations_used, "escalation_budget": REVIEW_PREFLIGHT_MAX_ESCALATIONS, "routes": routes, @@ -520,7 +750,7 @@ def _preflight_review_agents( raise ReviewPreflightError( "no provider route passed the Strix plain-chat preflight", report ) - return viable, report + return [*viable, *deferred], report def _preflight_with_fallback( @@ -531,9 +761,10 @@ def _preflight_with_fallback( The two stages share ADR-0005's one ``REVIEW_PREFLIGHT_MAX_ESCALATIONS`` budget for the whole preflight run, not one budget each: the primary stage's ending ``escalations_used`` is passed as the fallback stage's - starting point, so a run that rejects all 8 primary routes and then - probes 4 fallback routes still spends at most 4 escalations total (12 - base attempts + 4 escalations). This bounds request count, not individual + starting point, so a run that rejects all 16 primary candidates and then + probes 8 fallback candidates still spends at most 4 escalations total (at + most ``REVIEW_PREFLIGHT_MAX_PROBES`` base attempts per stage + 4 + escalations). This bounds request count, not individual model response or sidecar readiness time. Both stages' reports remain in the result: the fallback (or sole) stage's report carries the run's final, cumulative ``escalations_used``, and @@ -580,8 +811,9 @@ def _log_preflight_rejections(report: dict[str, object]) -> None: if not isinstance(routes, list): return for row in routes: - if not isinstance(row, dict) or row.get("status") != "rejected": + if not isinstance(row, dict) or row.get("status") not in ("rejected", "deferred"): continue + event = f"preflight_route_{row['status']}" # Re-validate rather than trust the caller's own sanitization: this # print reaches the sidecar's sanitized stderr stream unchanged, so an # out-of-contract value here (not a plain identifier) must degrade to @@ -601,13 +833,13 @@ def _log_preflight_rejections(report: dict[str, object]) -> None: http_status = row.get("http_status") if isinstance(http_status, int) and not isinstance(http_status, bool) and 100 <= http_status <= 599: print( - f"preflight_route_rejected provider={provider} " + f"{event} provider={provider} " f"error_type={error_type} http_status={http_status}", file=sys.stderr, ) else: print( - f"preflight_route_rejected provider={provider} error_type={error_type}", + f"{event} provider={provider} error_type={error_type}", file=sys.stderr, ) @@ -628,6 +860,10 @@ def _bounded_primary_catalog_limit( total_limit = min(requested_limit, REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES) if pool == "auto" and has_free_rows: return min(total_limit, REVIEW_PREFLIGHT_PRIMARY_ROUTE_LIMIT) + # ADR-0029: the production pool is ``free`` (no fallback stage) and lists + # the full two-stage budget. Candidates past REVIEW_PREFLIGHT_MAX_PROBES + # are reached only when the account-skip rule frees probes; the report's + # ``skipped_count`` keeps that tail distinguishable from an early stop. return total_limit @@ -673,6 +909,62 @@ def _catalog_account_cap(default: int) -> int: return int(os.environ.get("ORCHESTRATOR_CATALOG_ACCOUNT_CAP", str(default))) +DEFAULT_SIDECAR_LOG_LEVEL = "DEBUG" +SIDECAR_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s" + + +def _sidecar_log_level() -> str: + """Return the log level the review sidecar configures for its orchestrator process. + + Defaults to ``DEBUG`` because that is where ``contextual_orchestrator`` + records the per-request trace a failed review needs afterwards: every + provider attempt (``provider_attempt``), its classified failure + (``provider_attempt_failed`` with error type and transient flag), backoff, + and circuit events are ``_LOGGER.debug`` calls, while the default + ``WARNING`` level keeps only ``provider_exhausted``/``circuit_opened``. At + the vendored pin none of those DEBUG sites logs a prompt, payload, or + response body; the one free-text field is ``provider_attempt_failed``'s + ``error_message`` (the exception text, which can quote an upstream error + body), and the sidecar pipes this process's stderr through the allow-list + sanitizer before it reaches disk, so only lines the sanitizer recognises + -- and only their structured fields -- become CI evidence. On + 2026-09-05 a 3122 s ``noema-review`` failure could not be attributed to + "six ready routes, two retry layers, 548 s per hop" from the job log alone + because this trace was never emitted. Override with + ``ORCHESTRATOR_SIDECAR_LOG_LEVEL``. + """ + return os.environ.get("ORCHESTRATOR_SIDECAR_LOG_LEVEL", DEFAULT_SIDECAR_LOG_LEVEL) + + +def _configure_sidecar_logging(configure_logging: Callable[[str], None]) -> str: + """Configure the orchestrator process's logging for CI evidence. + + ``configure_logging`` is ``contextual_orchestrator.debug_logging.configure_logging`` + (injected so this module stays importable without the vendored package): + it installs the root level with ``basicConfig(force=True)``. Its default + formatter carries no timestamp, and a per-attempt trace without + timestamps cannot yield per-hop durations, so every root handler is then + given :data:`SIDECAR_LOG_FORMAT`. + + Returns: + The level name that was applied. + + Raises: + SystemExit: If ``ORCHESTRATOR_SIDECAR_LOG_LEVEL`` is not a level name + the orchestrator accepts; a misspelt level must not silently leave + the process at ``WARNING``. + """ + level = _sidecar_log_level() + try: + configure_logging(level) + except ValueError as exc: + raise SystemExit(f"ORCHESTRATOR_SIDECAR_LOG_LEVEL is invalid: {exc}") from None + formatter = logging.Formatter(SIDECAR_LOG_FORMAT) + for handler in logging.getLogger().handlers: + handler.setFormatter(formatter) + return level + + def _with_discovery_counts( report: dict[str, object], rows: list[dict[str, Any]], @@ -802,7 +1094,9 @@ def main(argv: list[str] | None = None) -> int: parse_discovery_report, provider_account, ) + from contextual_orchestrator.debug_logging import configure_logging + _configure_sidecar_logging(configure_logging) registered = register_review_credentials(os.environ) auth_token = args.auth_token or get_credential(REVIEW_AUTH_CREDENTIAL_NAME) if not auth_token: @@ -856,7 +1150,7 @@ def main(argv: list[str] | None = None) -> int: zdr_endpoints=zdr_endpoints, checker=is_zdr_model, ) - requested_catalog_limit = int(os.environ.get("ORCHESTRATOR_CATALOG_LIMIT", "12")) + requested_catalog_limit = int(os.environ.get("ORCHESTRATOR_CATALOG_LIMIT", "24")) primary_limit = _bounded_primary_catalog_limit( requested_catalog_limit, pool=args.pool, has_free_rows=bool(admitted_free_rows) ) diff --git a/scripts/ci/contextual_orchestrator_review_policy.py b/scripts/ci/contextual_orchestrator_review_policy.py index 910b8da3a9..e609e67ff3 100644 --- a/scripts/ci/contextual_orchestrator_review_policy.py +++ b/scripts/ci/contextual_orchestrator_review_policy.py @@ -13,6 +13,7 @@ from __future__ import annotations import argparse +import itertools import json import math import re @@ -273,6 +274,21 @@ def _free_pool_source_admitted(row: Mapping[str, Any]) -> bool: ) +def _route_tier(row: Mapping[str, Any], zdr_endpoints: frozenset[str]) -> tuple[int, int]: + """Return the ``(cost rank, ZDR rank)`` tier a route is selected within. + + Free routes rank before priced ones and ZDR-attested routes before + unattested ones; the tier is what the catalog fill must never reorder, + while accounts inside one tier may be interleaved freely. + """ + attested = is_zdr_model( + str(row["provider"]), + model=str(row["model"]), + zdr_endpoints=zdr_endpoints, + ) + return (_COST_EVIDENCE_RANK[_cost_evidence(row)], 0 if attested else 1) + + def build_zdr_prioritized_catalog( rows: Iterable[Mapping[str, Any]], *, @@ -317,27 +333,36 @@ def build_zdr_prioritized_catalog( ] eligible_rows.sort( key=lambda row: ( - _COST_EVIDENCE_RANK[_cost_evidence(row)], - 0 - if is_zdr_model( - str(row["provider"]), - model=str(row["model"]), - zdr_endpoints=zdr_endpoints, - ) - else 1, + *_route_tier(row, zdr_endpoints), str(row["provider"]), str(row["model"]), ) ) + # Fill each (cost, ZDR) tier round-robin across independently credentialed + # accounts. A plain sorted fill let the alphabetically first account take + # its whole cap before the next account saw a slot: on 2026-09-05 the review + # sidecar admitted 62 free routes across three accounts and served + # 8 nvidia_nim + 4 nvidia_nim_sub + 0 openrouter (limit 12, cap 8), so a + # stalled NVIDIA endpoint had no other account to fail over to + # (ContextualWisdomLab/.github#1476, contextual-orchestrator#1045). per_account: Counter[str] = Counter() picked: list[Mapping[str, Any]] = [] - for row in eligible_rows: - account = provider_account(str(row["provider"])) - if per_account[account] >= account_cap: - continue - per_account[account] += 1 - picked.append(row) + for _tier, tier_rows in itertools.groupby( + eligible_rows, key=lambda row: _route_tier(row, zdr_endpoints) + ): + queues: dict[str, list[Mapping[str, Any]]] = {} + for row in tier_rows: + queues.setdefault(provider_account(str(row["provider"])), []).append(row) + while queues and len(picked) < limit: + for account in list(queues): + if per_account[account] >= account_cap or not queues[account]: + del queues[account] + continue + picked.append(queues[account].pop(0)) + per_account[account] += 1 + if len(picked) >= limit: + break if len(picked) >= limit: break diff --git a/scripts/ci/contextual_orchestrator_review_sidecar.sh b/scripts/ci/contextual_orchestrator_review_sidecar.sh index 48bb3934f8..38d9551a32 100755 --- a/scripts/ci/contextual_orchestrator_review_sidecar.sh +++ b/scripts/ci/contextual_orchestrator_review_sidecar.sh @@ -14,7 +14,7 @@ # (fail-closed zero-cost) pool. set -euo pipefail -ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-045d17da5e2aea56a97e241ee158ab1628d78660}" +ORCHESTRATOR_PIN_SHA="${ORCHESTRATOR_PIN_SHA:-414f22973658c4ddc3d4320fcf7acd9b4e8ba991}" ORCHESTRATOR_GIT_URL="${ORCHESTRATOR_GIT_URL:-https://github.com/ContextualWisdomLab/contextual-orchestrator.git}" # The Strix gate and Noema SSRF guard accept this one process-local origin. # Keep it fixed so an environment override cannot create an unvalidated sidecar. @@ -35,11 +35,12 @@ SIDECAR_LOG_SANITIZER="$ORG_REPO_ROOT/scripts/ci/sanitize_contextual_orchestrato # finishes, letting the shell script wait for a deterministic marker instead # of guessing whether the async sanitizer has caught up. SIDECAR_DISCOVERY_DIAGNOSTICS_SENTINEL="discovery_diagnostics_complete" -CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-12}" +CATALOG_LIMIT="${ORCHESTRATOR_CATALOG_LIMIT:-24}" # Each KV credential is an independent account, including two credentials for # the same vendor or endpoint. The account cap prevents one credential from -# consuming the bounded twelve-route preflight catalog without inventing a -# provider-family equivalence relation. +# consuming the bounded preflight candidate list (24 candidates, probed lazily +# to a readiness target -- ADR-0029) without inventing a provider-family +# equivalence relation. CATALOG_ACCOUNT_CAP="${ORCHESTRATOR_CATALOG_ACCOUNT_CAP:-8}" ORCHESTRATOR_GITHUB_ENV="${GITHUB_ENV:-}" sidecar_python="$(command -v python3)" @@ -688,4 +689,7 @@ fi log "policy evidence summary:" sed -n '1,80p' "$policy_report" || true log "runtime preflight summary:" -sed -n '1,160p' "$preflight_report" || true +# 16 probed routes at 8-10 lines each plus the header run past the old +# 160-line cap exactly in the dead hour the summary matters most (ADR-0029); +# the artifact copy was always complete, only the job-log echo was cut. +sed -n '1,400p' "$preflight_report" || true diff --git a/scripts/ci/current_head_run_coalescer.py b/scripts/ci/current_head_run_coalescer.py index 0c58d32263..ae40b85ac4 100644 --- a/scripts/ci/current_head_run_coalescer.py +++ b/scripts/ci/current_head_run_coalescer.py @@ -15,6 +15,7 @@ import os import re import subprocess +import time from typing import Any, Iterable, Mapping, Sequence from urllib.parse import urlsplit @@ -26,6 +27,8 @@ PR_EVENTS = frozenset({"pull_request", "pull_request_target"}) ACTIVE_STATUSES = ("queued", "in_progress") API_TIMEOUT_SECONDS = 30 +CANCELLATION_POLL_ATTEMPTS = 6 +CANCELLATION_POLL_INTERVAL_SECONDS = 1.0 class CoalescingRefused(RuntimeError): @@ -369,8 +372,15 @@ def _fetch_run(repo: str, run_id: int) -> dict[str, Any]: def _cancel_run(repo: str, run_id: int) -> None: - """Request ordinary cancellation using the same explicit token/timeout contract.""" + """Cancel one run and prove GitHub reached its terminal cancelled state.""" _run_json(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/cancel"]) + for attempt in range(CANCELLATION_POLL_ATTEMPTS): + run_data = _fetch_run(repo, run_id) + if run_data.get("status") == "completed" and run_data.get("conclusion") == "cancelled": + return + if attempt + 1 < CANCELLATION_POLL_ATTEMPTS: + time.sleep(CANCELLATION_POLL_INTERVAL_SECONDS) + raise RuntimeError(f"workflow run {run_id} did not reach completed/cancelled") def _associated_prs( @@ -508,15 +518,27 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: def main(argv: Sequence[str] | None = None) -> int: - """Run the coalescer and fail closed on malformed or unavailable evidence.""" + """Run the coalescer, treating a live-state refusal as the documented safe no-op. + + `CoalescingRefused` raised by `coalesce()`'s own top-level live-PR-state check + (before any per-candidate cancellation is attempted) means this invocation's + remembered head no longer matches the live head -- the same "safe no-op" the + per-candidate loop inside `coalesce()` already treats as non-fatal, and the + production workflow's own comment documents as the intended behavior for a + superseded queued instance. Any other exception (malformed repository/PR + identity, an unavailable GitHub API) still fails closed. + """ args = parse_args(argv) - coalesce( - args.repo, - args.pr_number, - args.expected_head_repo, - args.expected_head_ref, - args.expected_head, - ) + try: + coalesce( + args.repo, + args.pr_number, + args.expected_head_repo, + args.expected_head_ref, + args.expected_head, + ) + except CoalescingRefused as exc: + print(f"No coalescing performed: {exc}") return 0 diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index f1c39a51bd..5ab7e830f3 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -6,17 +6,16 @@ import argparse import ast import base64 -import contextlib import hashlib import http.client import ipaddress import json import os import re -import signal import socket import subprocess import sys +import time import urllib.error import urllib.parse import urllib.request @@ -59,16 +58,145 @@ MAX_FILE_CONTEXT_CHARS = 4000 MAX_REVIEW_CONTEXT_CHARS = 24000 MAX_THREAD_BODY_CHARS = 1200 +MAX_ALLOWED_LOCATIONS_JSON_BYTES = 32 * 1024 +MAX_HTTP_ERROR_BODY_BYTES = 16 * 1024 DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") +SAFE_MODEL_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$") ORCHESTRATOR_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"}) ORCHESTRATOR_BASE_ENV = "CONTEXTUAL_ORCHESTRATOR_BASE_URL" -# A repair request corrects an already-completed model verdict; it is not a -# second unbounded full review. Fifteen minutes is an absolute wall-clock -# deadline for the complete corrective attempt (open/read/decode/validate), -# not a socket inactivity timeout. The primary review remains governed by -# contextual-orchestrator rather than a fixed inference timeout. -NOEMA_REPAIR_DEADLINE_SECONDS = 15 * 60 +# OpenAI Chat Completions structured-output envelope for the verdict shape +# ``validate_substantive_verdict`` enforces. contextual-orchestrator's +# ``orchestrator/free`` sidecar is proven (ADR-0003) to be an OpenAI- +# COMPATIBLE endpoint, so the outer envelope (``type`` / +# ``json_schema.name`` / ``json_schema.strict`` / ``json_schema.schema``) +# must be OpenAI's specific wrapping convention -- not bare JSON Schema and +# not Claude's tool-forcing convention. Only the inner ``schema`` value is +# the general JSON Schema document. Whether the gateway correctly translates +# this OpenAI-shaped request for a non-OpenAI-compatible backend it may +# route to is contextual-orchestrator's own translation responsibility, not +# this caller's: adding per-provider format detection here would recreate +# the layering violation the repo owner already rejected in PR #1602 one +# level down. ``strict: true`` requires every property to be listed in +# ``required`` (a conditionally-absent field is expressed as a nullable +# type, e.g. ``["array", "null"]``, never an omitted key) and every object +# to set ``additionalProperties: false``. +# +# ``adversarial_validation.probes`` carries a ``minItems`` floor built fresh +# per request from ``_required_probe_count`` rather than a fixed number: per +# ADR-0035 (`contextual-orchestrator`), the gateway parses the returned +# content and validates it against this exact declared schema -- provider +# acceptance of ``response_format`` is not proof of conformance -- and makes +# one governed same-provider repair call on a violation before this ever +# reaches Noema's own ``validate_substantive_verdict`` second pass. Without +# this floor, an insufficient-probe verdict (schema-valid JSON, just too few +# probes) reaches that second pass and fails the whole review outright with +# no earlier, cheaper structural catch -- exactly what happened in +# `ContextualWisdomLab/ConceptWeave` run `33527145686`, job `99920767480` +# ("Noema adversarial validation requires at least 2 concrete probe(s)"). +_NOEMA_REVIEWED_LINE_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "path": {"type": "string"}, + "line": {"type": "integer"}, + "side": {"type": "string", "enum": ["LEFT", "RIGHT"]}, + "analysis": {"type": "string"}, + }, + "required": ["path", "line", "side", "analysis"], +} +_NOEMA_PROBE_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "path": {"type": "string"}, + "line": {"type": "integer"}, + "side": {"type": "string", "enum": ["LEFT", "RIGHT"]}, + "hypothesis": {"type": "string"}, + "attack_or_counterexample": {"type": "string"}, + "evidence": {"type": "string"}, + "outcome": {"type": "string", "enum": ["falsified", "confirmed"]}, + }, + "required": [ + "path", + "line", + "side", + "hypothesis", + "attack_or_counterexample", + "evidence", + "outcome", + ], +} +_NOEMA_FINDING_SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "properties": { + "severity": {"type": "string", "enum": ["high", "medium", "low"]}, + "file": {"type": "string"}, + "line": {"type": "integer"}, + "side": {"type": "string", "enum": ["LEFT", "RIGHT"]}, + "message": {"type": "string"}, + }, + "required": ["severity", "file", "line", "side", "message"], +} +def _noema_verdict_json_schema(required_probes: int) -> dict[str, Any]: + """Build the verdict JSON Schema with this request's exact probe floor. + + ``required_probes`` must come from ``_required_probe_count(diff, + changed_paths)`` -- the same call ``validate_substantive_verdict`` uses + -- so the gateway-enforced structural floor and the Python-side backstop + can never silently diverge. The static per-field schemas above are safe + to share by reference here since nothing in this module mutates them. + """ + return { + "type": "object", + "additionalProperties": False, + "properties": { + "decision": { + "type": "string", + "enum": ["approve", "request_changes", "comment"], + }, + "summary": {"type": "string"}, + "reviewed_lines": { + "type": ["array", "null"], + "items": _NOEMA_REVIEWED_LINE_SCHEMA, + }, + "adversarial_validation": { + "type": ["object", "null"], + "additionalProperties": False, + "properties": { + "status": {"type": "string", "enum": ["passed", "failed"]}, + "residual_risk": {"type": "string"}, + "probes": { + "type": "array", + "minItems": required_probes, + "items": _NOEMA_PROBE_SCHEMA, + }, + }, + "required": ["status", "residual_risk", "probes"], + }, + "findings": {"type": "array", "items": _NOEMA_FINDING_SCHEMA}, + }, + "required": [ + "decision", + "summary", + "reviewed_lines", + "adversarial_validation", + "findings", + ], + } + + +def _noema_verdict_response_format(required_probes: int) -> dict[str, Any]: + """Build the OpenAI ``response_format`` envelope for this request's probe floor.""" + return { + "type": "json_schema", + "json_schema": { + "name": "noema_review_verdict", + "strict": True, + "schema": _noema_verdict_json_schema(required_probes), + }, + } class NoemaModelOutputError(RuntimeError): @@ -79,9 +207,6 @@ class NoemaTransportError(RuntimeError): """Raised when the bounded review transport cannot produce usable evidence.""" -class NoemaRepairDeadlineExceeded(TimeoutError): - """Raised when the corrective attempt exceeds its total wall-clock budget.""" - def _stable_failure_diagnostic(exc: BaseException) -> str: """Return actionable trusted diagnostics without reflecting model values.""" @@ -423,48 +548,34 @@ def parse_diff_path(raw: str, prefix: str) -> str: return value.removeprefix(prefix) -def _entry_ordinal(position: int, total: int) -> str: - """Return an unambiguous array-position label for a validated JSON entry. - - ``position`` is the entry's 1-based place in the array being validated — - an array position, not a source-code line number. The historical message - text ("Noema reviewed line N is not an exact changed-side line") read as - if N named literal file line N; it only ever named "the Nth entry" of - ``reviewed_lines``/``probes``, so two failures on entries 1 and 3 of a - 3-entry array could be misread as complaints about file lines 1 and 3 - (see the naruon#1503 investigation this fixes). Every caller splices this - immediately after the fixed ``"Noema reviewed line "``/``"Noema - adversarial probe "`` prefix so ``_stable_failure_diagnostic``'s - trusted-prefix allowlist still recognizes the message as trusted - structural validator output. +def _required_probe_count(diff: str, changed_paths: Sequence[str] = ()) -> int: + """Return the minimum adversarial-probe count a formal verdict must carry. + + This is the single source of truth shared by the structured-output schema + and deterministic local validator. Executable/test/workflow changes require + two distinct probes; other diffs require one. The bound is cardinality- + based and independent of repository path count, so a near-MAX_DIFF_CHARS + review remains representable within the gateway output budget. """ + locations = changed_diff_locations(diff) + all_changed_paths = set(changed_paths) or {path for path, _line, _side in locations} + return 2 if any(changed_file_is_material(path) for path in all_changed_paths) else 1 + + +def _entry_ordinal(position: int, total: int) -> str: + """Return an unambiguous 1-based array-position label for diagnostics.""" return f"entry {position}/{total} (array index {position - 1}, not a source line)" def _format_location(path: Any, line: Any, side: Any) -> str: - """Format one rejected path/line/side citation for a diagnostic message. - - ``repr()`` on each raw value (rather than plain interpolation) keeps a - non-string ``path``, a non-int ``line``, or a ``None`` deliberately - distinguishable in the rendered text instead of silently coercing to a - misleading string. - """ + """Format one rejected path/line/side citation without coercing its types.""" return f"path={path!r} line={line!r} side={side!r}" def _nearby_changed_locations( locations: set[tuple[str, int, str]], path: Any, line: Any, *, limit: int = 5 ) -> str: - """Return a short hint of the closest real changed locations sharing ``path``. - - Scoped to ``locations`` entries whose path matches ``path`` exactly, then - sorted nearest-line-first (so a citation just one line off a real changed - line is obviously close, rather than buried in an unsorted dump) and - capped at ``limit`` entries to keep the GitHub Actions ``::error::`` - annotation this feeds into readable. Returns ``""`` — no hint — when - ``path`` is not a string or no changed location shares it; there is - nothing useful to compare against. - """ + """Return a bounded nearest-line hint for the rejected path.""" if not isinstance(path, str): return "" same_path = [location for location in locations if location[0] == path] @@ -483,7 +594,7 @@ def _nearby_changed_locations( def validate_substantive_verdict( verdict: dict[str, Any], diff: str, changed_paths: Sequence[str] = () ) -> None: - """Reject formal verdicts without changed-line and adversarial evidence.""" + """Reject formal verdicts without exact changed-line/adversarial evidence.""" decision = str(verdict.get("decision") or "").lower() if decision == "comment": return @@ -522,10 +633,11 @@ def validate_substantive_verdict( if not isinstance(residual_risk, str) or not residual_risk.strip(): raise NoemaModelOutputError("Noema adversarial validation requires residual_risk") probes = validation.get("probes") - all_changed_paths = set(changed_paths) or {path for path, _line, _side in locations} - required_probes = 2 if any(changed_file_is_material(path) for path in all_changed_paths) else 1 + required_probes = _required_probe_count(diff, changed_paths) if not isinstance(probes, list) or len(probes) < required_probes: - raise NoemaModelOutputError(f"Noema adversarial validation requires at least {required_probes} concrete probe(s)") + raise NoemaModelOutputError( + f"Noema adversarial validation requires at least {required_probes} concrete probe(s)" + ) confirmed: set[tuple[str, int, str]] = set() identities: set[tuple[Any, ...]] = set() @@ -548,8 +660,14 @@ def validate_substantive_verdict( raise NoemaModelOutputError(f"Noema adversarial probe {entry} requires {field}") outcome = probe.get("outcome") if outcome not in {"falsified", "confirmed"}: - raise NoemaModelOutputError(f"Noema adversarial probe {entry} outcome must be falsified or confirmed") - identity = (*location, probe["hypothesis"].strip().casefold(), probe["attack_or_counterexample"].strip().casefold()) + raise NoemaModelOutputError( + f"Noema adversarial probe {entry} outcome must be falsified or confirmed" + ) + identity = ( + *location, + probe["hypothesis"].strip().casefold(), + probe["attack_or_counterexample"].strip().casefold(), + ) if identity in identities: raise NoemaModelOutputError(f"Noema adversarial probe {entry} duplicates an earlier probe") identities.add(identity) @@ -565,7 +683,9 @@ def validate_substantive_verdict( if isinstance(finding, dict) } if not confirmed or not confirmed.intersection(finding_locations): - raise NoemaModelOutputError("Noema request_changes requires a confirmed probe on a published finding") + raise NoemaModelOutputError( + "Noema request_changes requires a confirmed probe on a published finding" + ) def truncate_text(text: str, limit: int) -> str: @@ -866,7 +986,77 @@ def _json_nesting_within_bound(text: str, start: int, max_depth: int) -> bool: MAX_JSON_NESTING_DEPTH = 100 +def _strip_trailing_commas_outside_strings(text: str) -> str: + """Remove only a genuine trailing comma after a complete JSON value. + + Missing-value forms such as ``[,]``, ``{,}``, ``[1,,]`` and ``{"a":,}`` + remain malformed and therefore fail closed. String contents are untouched. + """ + result: list[str] = [] + in_string = False + escaped = False + index = 0 + length = len(text) + while index < length: + char = text[index] + if in_string: + result.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + index += 1 + continue + if char == '"': + in_string = True + result.append(char) + index += 1 + continue + if char == ",": + lookahead = index + 1 + while lookahead < length and text[lookahead] in " \t\r\n": + lookahead += 1 + previous = len(result) - 1 + while previous >= 0 and result[previous] in " \t\r\n": + previous -= 1 + prior = result[previous] if previous >= 0 else "" + value_ending = prior in {'"', '}', ']'} or prior.isdigit() or prior in {'e', 'l'} + if lookahead < length and text[lookahead] in "}]" and value_ending: + index += 1 + continue + result.append(char) + index += 1 + return "".join(result) + + def extract_json_object(text: str) -> dict[str, Any]: + """Extract a JSON object, retrying once through a lossless local repair. + + Delegates to ``_extract_json_object_once``. If that fails, this makes + exactly one additional attempt against + ``_strip_trailing_commas_outside_strings(text)`` -- a deterministic, + semantically lossless fixup for the single well-known trailing-comma + malformation class -- before giving up. This is a local, non-network + second chance: it can resolve some malformed-JSON cases without ever + spending the bounded repair path's network round trip and wall-clock + budget, and it emits a ``::notice::`` (no raw content) when it is what + actually rescued the response, since that is itself useful repair-path + telemetry. It does not attempt to guess-repair any other malformation + shape; those still fail closed exactly as before. + """ + try: + return _extract_json_object_once(text) + except NoemaModelOutputError: + repaired = _strip_trailing_commas_outside_strings(text.strip()) + if repaired == text.strip(): + raise + verdict = _extract_json_object_once(repaired) + return verdict + + +def _extract_json_object_once(text: str) -> dict[str, Any]: """Extract a JSON object from a strict or lightly wrapped LLM response. Fails closed with ``NoemaModelOutputError`` — the same "no usable verdict" failure @@ -1108,6 +1298,125 @@ def decode_llm_response_body(raw_bytes: bytes) -> str: ) from exc +def _extract_served_model(raw: str) -> str | None: + """Return a bounded, scrubbed, single-line UTF-8-printable serving model id.""" + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError, ValueError): + return None + if not isinstance(data, dict): + return None + return _safe_model_identifier(data.get("model")) + + +def _safe_model_identifier(value: Any) -> str | None: + """Accept only a conservative, bounded model identifier safe for public logs.""" + if not isinstance(value, str): + return None + candidate = value.strip() + if not SAFE_MODEL_IDENTIFIER_RE.fullmatch(candidate): + return None + return candidate + + +def _extract_http_error_telemetry(exc: urllib.error.HTTPError) -> dict[str, str | int]: + """Read bounded, allowlisted gateway failure telemetry without raw diagnostics. + + The response body is never returned or logged. Only the canonical + ``error.detail`` receipt fields are allowed; malformed, oversized, or + unexpected envelopes fail closed to no telemetry. + """ + try: + raw_bytes = exc.read(MAX_HTTP_ERROR_BODY_BYTES + 1) + except (AttributeError, OSError, ValueError, http.client.HTTPException): + return {} + if len(raw_bytes) > MAX_HTTP_ERROR_BODY_BYTES: + return {} + try: + payload = json.loads(raw_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError): + return {} + if not isinstance(payload, dict): + return {} + error = payload.get("error") + if not isinstance(error, dict): + return {} + detail = error.get("detail") + if not isinstance(detail, dict): + return {} + telemetry: dict[str, str | int] = {} + model = _safe_model_identifier(detail.get("model")) + terminal_reason = _safe_model_identifier(detail.get("terminal_reason")) + attempts = detail.get("attempts") + if model is not None: + telemetry["served_model"] = model + if terminal_reason is not None: + telemetry["terminal_reason"] = terminal_reason + if isinstance(attempts, list) and attempts and len(attempts) <= 64: + last_attempt = attempts[-1] + if isinstance(last_attempt, dict): + provider_name = _safe_model_identifier(last_attempt.get("provider_name")) + phase = _safe_model_identifier(last_attempt.get("phase")) + attempt_number = last_attempt.get("attempt_number") + provider_status = last_attempt.get("provider_status") + if provider_name is not None: + telemetry["provider_name"] = provider_name + if phase is not None: + telemetry["upstream_phase"] = phase + if type(attempt_number) is int and 1 <= attempt_number <= 64: + telemetry["attempt_number"] = attempt_number + if type(provider_status) is int and 100 <= provider_status <= 599: + telemetry["upstream_status"] = provider_status + return telemetry + + +def _extract_http_error_served_model(exc: urllib.error.HTTPError) -> str | None: + """Return the safe served model from one bounded gateway error envelope.""" + model = _extract_http_error_telemetry(exc).get("served_model") + return model if isinstance(model, str) else None + + +def _format_gateway_error_telemetry(telemetry: dict[str, str | int]) -> str: + """Format only allowlisted scalar receipt fields for a public Actions log.""" + ordered_keys = ( + "provider_name", + "upstream_phase", + "attempt_number", + "upstream_status", + "terminal_reason", + ) + return " ".join( + f"{key}={telemetry[key]}" for key in ordered_keys if key in telemetry + ) + + +def _bounded_allowed_locations_json(allowed_locations: Sequence[dict[str, Any]]) -> str: + """Serialize the largest location prefix that fits the prompt byte budget.""" + total_count = len(allowed_locations) + + def render(count: int) -> str: + """Serialize the first `count` locations, flagged as truncated if fewer than all.""" + return json.dumps( + { + "total_count": total_count, + "truncated": count < total_count, + "locations": list(allowed_locations[:count]), + }, + ensure_ascii=False, + separators=(",", ":"), + ) + + low = 0 + high = total_count + while low < high: + midpoint = (low + high + 1) // 2 + if len(render(midpoint).encode("utf-8")) <= MAX_ALLOWED_LOCATIONS_JSON_BYTES: + low = midpoint + else: + high = midpoint - 1 + return render(low) + + def _truthy_env(name: str) -> bool: """Return whether a process environment flag is an explicit truthy value.""" return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} @@ -1195,47 +1504,6 @@ def reject_private_llm_url(api_url: str) -> None: raise ValueError("URL cannot target internal IP addresses") -@contextlib.contextmanager -def _repair_wall_clock_deadline(seconds: float): - """Interrupt the entire corrective attempt after ``seconds`` of wall time. - - ``urllib``'s timeout is a socket-operation timeout and can be extended by - trickling bytes. Required Noema Review runs on Linux, so ITIMER_REAL gives - the repair attempt one process-level wall-clock budget across open, read, - decode, and deterministic validation. An existing process alarm is not - overwritten; that condition fails closed instead. - """ - if seconds <= 0: - raise ValueError("repair wall-clock deadline must be positive") - if not hasattr(signal, "setitimer") or not hasattr(signal, "ITIMER_REAL"): - raise RuntimeError("repair wall-clock deadline requires POSIX setitimer support") - previous_remaining, previous_interval = signal.getitimer(signal.ITIMER_REAL) - if previous_remaining > 0 or previous_interval > 0: - raise RuntimeError("repair wall-clock deadline refused to overwrite an active process alarm") - previous_handler = signal.getsignal(signal.SIGALRM) - - def expire(_signum, _frame): - """Raise the typed deadline signal without reflecting response content.""" - raise NoemaRepairDeadlineExceeded( - f"Noema repair exceeded {seconds:g}-second absolute wall-clock deadline" - ) - - try: - signal.signal(signal.SIGALRM, expire) - except ValueError as exc: - raise RuntimeError("repair wall-clock deadline must run on the process main thread") from exc - signal.setitimer(signal.ITIMER_REAL, seconds) - try: - yield - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, previous_handler) - - -class StaleHeadDuringRepairRetryError(RuntimeError): - """Raised when the PR head moves before ``call_llm``'s repair-retry request fires.""" - - def call_llm( repo: str, number: int, @@ -1245,94 +1513,44 @@ def call_llm( expected_head: str, review_context: str = "", changed_paths: Sequence[str] = (), - repair_error: str = "", - is_retry: bool = False, ) -> dict[str, Any]: - """Call the configured OpenAI-compatible LLM endpoint for a review verdict. - - ``expected_head`` is the same normalized (lowercase) SHA - ``inspect_and_review`` already checks before model work and before - publication. It is threaded through here so the one-time repair-retry - request below — fired only after the first attempt's verdict was - malformed — can also confirm the PR head has not moved before spending a - second, potentially multi-hour model call on a - review that ``inspect_and_review``'s own post-call stale-head check would - discard anyway once this function returns. See ``fetch_pr`` for the live - lookup and ``StaleHeadDuringRepairRetryError`` for how that stale - condition is reported distinctly to the caller. - - ``is_retry`` tracks retry state independently of ``repair_error``'s text: - several transport exceptions (a bare ``OSError``/``TimeoutError`` or - ``http.client.HTTPException`` raised with no message) stringify to an - empty string, so gating on ``repair_error``'s truthiness alone would let - an empty-message failure retry unboundedly instead of failing closed - after one attempt. + """Issue exactly one structured-output request through contextual-orchestrator. + + The gateway owns provider discovery, schema repair, candidate exclusion, + failover, and model timeouts. This caller therefore performs one request, + carries no fixed model wall-clock deadline or sampling temperature, and + fails closed if the gateway does not return a locally valid verdict. + Publication still performs a fresh exact-head check after model work. """ api_url = os.environ.get("NOEMA_LLM_API_URL", "").strip() api_key = os.environ.get("NOEMA_LLM_API_KEY", "").strip() - model = os.environ.get("NOEMA_LLM_MODEL", "").strip() or "noema-default" + model = os.environ.get("NOEMA_LLM_MODEL", "").strip() or "orchestrator/free" if not api_url or not api_key: - raise RuntimeError("Noema LLM review unavailable: NOEMA_LLM_API_URL or NOEMA_LLM_API_KEY is not configured.") + raise RuntimeError( + "Noema LLM review unavailable: NOEMA_LLM_API_URL or NOEMA_LLM_API_KEY is not configured." + ) reject_private_llm_url(api_url) allowed_locations = [ {"path": path, "line": line, "side": side} for path, line, side in sorted(changed_diff_locations(diff)) ] - location_example = ( - allowed_locations[0] - if allowed_locations - else {"path": "path", "line": 0, "side": "RIGHT"} - ) - + location_example = allowed_locations[0] if allowed_locations else { + "path": "path", "line": 0, "side": "RIGHT" + } + allowed_locations_json = _bounded_allowed_locations_json(allowed_locations) prompt = { "role": "user", "content": "\n".join( [ "You are Noema, an independent pull request reviewer for ContextualWisdomLab.", "Review the PR diff plus the additional changed-file and review-thread context for correctness, security, maintainability, and behavioral regressions.", - "Return only JSON with this shape:", - json.dumps( - { - "decision": "approve|request_changes|comment", - "summary": "...", - "reviewed_lines": [{**location_example, "analysis": "..."}], - "adversarial_validation": { - "status": "passed|failed", - "residual_risk": "...", - "probes": [ - { - **location_example, - "hypothesis": "...", - "attack_or_counterexample": "...", - "evidence": "observed or source-traced result", - "outcome": "falsified|confirmed", - } - ], - }, - "findings": [ - { - "severity": "high|medium|low", - "file": location_example["path"], - "line": location_example["line"], - "side": location_example["side"], - "message": "...", - } - ], - }, - separators=(",", ":"), - ), + "Return only JSON with the declared response_format schema.", "Every formal verdict must cite exact changed-side lines. APPROVE requires falsifying concrete regression hypotheses; source or test changes require at least two distinct probes and other changes require at least one. REQUEST_CHANGES requires a confirmed probe at a finding location.", + "Use only path, line, and side tuples listed in the bounded allowed-locations JSON below. If it is truncated, omit a formal verdict for any location not listed instead of guessing.", + f"Allowed changed-side locations: {allowed_locations_json}", + f"Location shape example: {json.dumps(location_example, separators=(',', ':'))}", "Use request_changes only for blocking, concrete issues. A generic no-issues statement is not review evidence.", - *( - [ - "Your prior verdict was rejected by the trusted validator: " - f"{repair_error or 'no diagnostic message was available'}", - "Return one corrected JSON verdict using only exact changed-side locations from the supplied diff.", - ] - if is_retry - else [] - ), f"Repository: {repo}", f"PR: #{number}", f"Title: {pr.get('title') or ''}", @@ -1347,7 +1565,9 @@ def call_llm( } payload = { "model": model, - "temperature": 0, + "response_format": _noema_verdict_response_format( + _required_probe_count(diff, changed_paths) + ), "messages": [ {"role": "system", "content": "Return strict JSON only. Do not include markdown."}, prompt, @@ -1363,82 +1583,94 @@ def call_llm( method="POST", ) opener = urllib.request.build_opener(NoRedirectHandler()) + attempt_started = time.monotonic() + active_phase = "connecting" + served_model: str | None = None try: - deadline_context = ( - _repair_wall_clock_deadline(NOEMA_REPAIR_DEADLINE_SECONDS) - if is_retry - else contextlib.nullcontext() - ) - with deadline_context: - with opener.open(request) as response: # nosec B310 - raw_bytes = response.read() - raw = decode_llm_response_body(raw_bytes) - content = extract_llm_message_content(raw) - verdict = extract_json_object(content) - decision = str(verdict.get("decision") or "").strip().lower() - if decision not in {"approve", "request_changes", "comment"}: - raise NoemaModelOutputError(f"Noema LLM returned unsupported decision: {decision!r}") - summary = verdict.get("summary") - if not isinstance(summary, str) or not summary.strip(): - raise NoemaModelOutputError("Noema LLM response did not contain a substantive summary") - findings = verdict.get("findings") - if not isinstance(findings, list) or any(not isinstance(finding, dict) for finding in findings): - raise NoemaModelOutputError("Noema LLM response findings must be a list of objects") - for finding in findings: - if ( - finding.get("severity") not in {"high", "medium", "low"} - or not isinstance(finding.get("file"), str) - or not finding["file"].strip() - or type(finding.get("line")) is not int - or finding["line"] <= 0 - or finding.get("side") not in {"RIGHT", "LEFT"} - or not isinstance(finding.get("message"), str) - or not finding["message"].strip() - ): - raise NoemaModelOutputError("Noema LLM response contained a malformed finding") - if decision == "request_changes" and not findings: - raise NoemaModelOutputError("Noema LLM request_changes response did not contain a substantive finding") - validate_substantive_verdict(verdict, diff, changed_paths) - except (RuntimeError, urllib.error.URLError, http.client.HTTPException, OSError) as exc: - current_failure = _stable_failure_diagnostic(exc) - if is_retry: - initial_failure = ( - scrub_sensitive_data(repair_error) - or "no diagnostic message was available" + with opener.open(request) as response: # nosec B310 + active_phase = "reading" + raw_bytes = response.read() + active_phase = "decoding" + raw = decode_llm_response_body(raw_bytes) + served_model = _extract_served_model(raw) + content = extract_llm_message_content(raw) + verdict = extract_json_object(content) + active_phase = "validating" + decision = str(verdict.get("decision") or "").strip().lower() + if decision not in {"approve", "request_changes", "comment"}: + raise NoemaModelOutputError( + f"Noema LLM returned unsupported decision: {decision!r}" ) - if isinstance(exc, NoemaModelOutputError): - raise NoemaModelOutputError( - "Noema model-output repair remained invalid; " - f"initial failure: {initial_failure}; repair failure: {current_failure}" - ) from None - if isinstance( - exc, (urllib.error.URLError, http.client.HTTPException, OSError) + summary = verdict.get("summary") + if not isinstance(summary, str) or not summary.strip(): + raise NoemaModelOutputError( + "Noema LLM response did not contain a substantive summary" + ) + findings = verdict.get("findings") + if not isinstance(findings, list) or any( + not isinstance(finding, dict) for finding in findings + ): + raise NoemaModelOutputError( + "Noema LLM response findings must be a list of objects" + ) + for finding in findings: + if ( + finding.get("severity") not in {"high", "medium", "low"} + or not isinstance(finding.get("file"), str) + or not finding["file"].strip() + or type(finding.get("line")) is not int + or finding["line"] <= 0 + or finding.get("side") not in {"RIGHT", "LEFT"} + or not isinstance(finding.get("message"), str) + or not finding["message"].strip() ): - raise NoemaTransportError( - "Noema bounded repair transport was exhausted; " - f"initial failure: {initial_failure}; repair failure: " - f"{type(exc).__name__}: {current_failure}" - ) from exc - raise RuntimeError( - "Noema repair failed closed; " - f"initial failure: {initial_failure}; repair failure: {current_failure}" - ) from exc - if str(fetch_pr(repo, number).get("headRefOid") or "").lower() != expected_head: - raise StaleHeadDuringRepairRetryError( - "Pull request head changed during review; stale before repair retry." - ) from exc - return call_llm( - repo, - number, - pr, - diff, - truncated, - expected_head, - review_context, - changed_paths, - current_failure, - is_retry=True, + raise NoemaModelOutputError( + "Noema LLM response contained a malformed finding" + ) + if decision == "request_changes" and not findings: + raise NoemaModelOutputError( + "Noema LLM request_changes response did not contain a substantive finding" + ) + validate_substantive_verdict(verdict, diff, changed_paths) + except (RuntimeError, urllib.error.URLError, http.client.HTTPException, OSError) as exc: + gateway_telemetry: dict[str, str | int] = {} + if isinstance(exc, urllib.error.HTTPError): + active_phase = "response_error" + gateway_telemetry = _extract_http_error_telemetry(exc) + model_value = gateway_telemetry.get("served_model") + served_model = model_value if isinstance(model_value, str) else None + elapsed = time.monotonic() - attempt_started + current_failure = _stable_failure_diagnostic(exc) + model_note = served_model or "unknown" + gateway_note = _format_gateway_error_telemetry(gateway_telemetry) + print( + f"::warning::Noema gateway attempt outcome=failed phase={active_phase} " + f"duration={elapsed:.1f}s served_model={model_note}; " + "caller attempts=1 (gateway owns repair/failover)." + + (f" gateway {gateway_note}" if gateway_note else "") + ) + suffix = ( + f"; caller attempts=1, duration={elapsed:.1f}s, " + f"phase={active_phase}, served_model={model_note}" + + (f", gateway {gateway_note}" if gateway_note else "") ) + if isinstance(exc, NoemaModelOutputError): + raise NoemaModelOutputError( + f"Noema model output failed local validation: {current_failure}{suffix}" + ) from None + if isinstance(exc, (urllib.error.URLError, http.client.HTTPException, OSError)): + raise NoemaTransportError( + f"Noema gateway transport failed: {type(exc).__name__}: {current_failure}{suffix}" + ) from exc + raise RuntimeError( + f"Noema review failed closed: {current_failure}{suffix}" + ) from exc + elapsed = time.monotonic() - attempt_started + print( + f"::notice::Noema gateway attempt outcome=success phase={active_phase} " + f"duration={elapsed:.1f}s served_model={served_model or 'unknown'}; " + "caller attempts=1." + ) return verdict @@ -1527,8 +1759,7 @@ def inspect_and_review(repo: str, number: int, expected_head: str) -> int: """Inspect PR state and submit Noema's independent LLM review. ``expected_head`` is normalized defensively before the stale-head - comparisons below, and before the one ``call_llm`` performs on its own - repair-retry path (see ``StaleHeadDuringRepairRetryError``). The CLI and + comparisons below and the post-model publication check. The CLI and workflow require canonical lowercase SHA input so equivalent casing cannot split the workflow concurrency group. """ @@ -1557,11 +1788,7 @@ def inspect_and_review(repo: str, number: int, expected_head: str) -> int: changed_files = fetch_changed_files(repo, number) changed_paths = tuple(path for path, _status in changed_files) review_context = build_review_context(repo, number, pr, changed_files) - try: - verdict = call_llm(repo, number, pr, diff, truncated, expected_head, review_context, changed_paths) - except StaleHeadDuringRepairRetryError: - print("Pull request head changed during review; Noema review skipped before repair retry.") - return 0 + verdict = call_llm(repo, number, pr, diff, truncated, expected_head, review_context, changed_paths) current_pr = fetch_pr(repo, number) try: require_expected_head(current_pr, expected_head) diff --git a/scripts/ci/opencode_adversarial_receipts.py b/scripts/ci/opencode_adversarial_receipts.py index 9d97cccf7e..f0880d9d68 100644 --- a/scripts/ci/opencode_adversarial_receipts.py +++ b/scripts/ci/opencode_adversarial_receipts.py @@ -189,8 +189,6 @@ def collect_receipts( valid_lines = [ line for line in changed_lines if 1 <= line <= len(source_lines) ] - if not valid_lines: - valid_lines = [1] for line in select_bounded_lines(valid_lines, lines_per_file): digest = hashlib.sha256(source_lines[line - 1]).hexdigest() receipts.append(SourceLineReceipt(path=path, line=line, digest=digest)) diff --git a/scripts/ci/opencode_repository_dispatch_targets.json b/scripts/ci/opencode_repository_dispatch_targets.json new file mode 100644 index 0000000000..dd82dd1fd0 --- /dev/null +++ b/scripts/ci/opencode_repository_dispatch_targets.json @@ -0,0 +1,58 @@ +{ + "$comment": "Mirrors the live ContextualWisdomLab/.github repository variable OPENCODE_REPOSITORY_DISPATCH_TARGETS, which gates ALLOWED_TARGET_REPOSITORIES in pr-review-merge-scheduler.yml/pr-review-fix-scheduler.yml and the agent-mention dispatch allowlist. There is no API to commit an org/repo variable's value to source control, so this file is a hand-maintained mirror -- update it AND run `gh variable set OPENCODE_REPOSITORY_DISPATCH_TARGETS --repo ContextualWisdomLab/.github` in the same PR whenever a repository is added. tests/test_hourly_review_repair_callers.py::test_every_hourly_caller_target_is_in_the_dispatch_targets_mirror locks every repository hourly-review-repair.yml dispatches to as a subset of this list -- see docs/doctoring/scheduler-target-list-drift-20260902.md for the incident history (governance-risk-compliance, nonnest2, quarantine-sandbox-runtime all silently failed their hourly heartbeat because this sync was missed) that this file and test exist to catch before it recurs.", + "targets": [ + "ContextualWisdomLab/.github", + "ContextualWisdomLab/ContextualWisdomLab.github.io", + "ContextualWisdomLab/DiagramWeave", + "ContextualWisdomLab/EgressWeave", + "ContextualWisdomLab/EmbedRelay", + "ContextualWisdomLab/IRT-bibliography-set", + "ContextualWisdomLab/LineageWeave", + "ContextualWisdomLab/OriginWeave", + "ContextualWisdomLab/Orgmetra", + "ContextualWisdomLab/RankWeave", + "ContextualWisdomLab/TEPP", + "ContextualWisdomLab/ThreadWeave", + "ContextualWisdomLab/aFIPC", + "ContextualWisdomLab/accounting-information-platform", + "ContextualWisdomLab/appguardrail", + "ContextualWisdomLab/bandscope", + "ContextualWisdomLab/ccube-jco-potential-customer", + "ContextualWisdomLab/clearfolio", + "ContextualWisdomLab/codec-carver", + "ContextualWisdomLab/context-graph-contracts", + "ContextualWisdomLab/contextual-orchestrator", + "ContextualWisdomLab/disksage", + "ContextualWisdomLab/enterprise-architecture-core", + "ContextualWisdomLab/fast-mlsirm", + "ContextualWisdomLab/feelanet-adfs", + "ContextualWisdomLab/four-pillars", + "ContextualWisdomLab/governance-risk-compliance", + "ContextualWisdomLab/gyeot", + "ContextualWisdomLab/hyosung-itx-slogan-brief", + "ContextualWisdomLab/inkspan", + "ContextualWisdomLab/kaefa", + "ContextualWisdomLab/keyverse", + "ContextualWisdomLab/learning-management-platform", + "ContextualWisdomLab/life-os", + "ContextualWisdomLab/linux-cluster-ops", + "ContextualWisdomLab/macos_utility_packs", + "ContextualWisdomLab/metering-billing-platform", + "ContextualWisdomLab/mhtml-etl-gateway", + "ContextualWisdomLab/mightyETL", + "ContextualWisdomLab/naruon", + "ContextualWisdomLab/newsdom-api", + "ContextualWisdomLab/noema", + "ContextualWisdomLab/nonnest2", + "ContextualWisdomLab/pg-erd-cloud", + "ContextualWisdomLab/pg-llm-batch", + "ContextualWisdomLab/psychometrics-commons", + "ContextualWisdomLab/quarantine-sandbox-runtime", + "ContextualWisdomLab/saju-caldav", + "ContextualWisdomLab/scopeweave", + "ContextualWisdomLab/semantic-data-portal", + "ContextualWisdomLab/wardnet", + "ContextualWisdomLab/xtrm-lead-pi-outbound", + "ContextualWisdomLab/xtrmLLMBatchPython" + ] +} diff --git a/scripts/ci/opencode_review_approve_gate.sh b/scripts/ci/opencode_review_approve_gate.sh index bf21c0b4a5..ba7c6cc244 100755 --- a/scripts/ci/opencode_review_approve_gate.sh +++ b/scripts/ci/opencode_review_approve_gate.sh @@ -219,6 +219,10 @@ import sys from pathlib import Path +# ⚡ Bolt: 반복문/자주 호출되는 함수 내에서 동일한 정규식 패턴을 지속적으로 생성하는 것을 방지하여 캐시 조회 오버헤드 감소 및 성능 향상 +HUNK_HEADER_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") + + source_root = Path(sys.argv[1]).resolve() control_file = Path(sys.argv[2]) control = json.loads(control_file.read_text(encoding="utf-8")) @@ -265,9 +269,9 @@ def changed_new_lines(path_value: str) -> frozenset[int]: return frozenset() line_numbers: set[int] = set() - hunk_header = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") + for raw_line in completed.stdout.splitlines(): - match = hunk_header.match(raw_line) + match = HUNK_HEADER_RE.match(raw_line) if not match: continue start = int(match.group(1)) diff --git a/scripts/ci/opencode_review_surfaces.py b/scripts/ci/opencode_review_surfaces.py index b314cddf00..55756d1ea9 100644 --- a/scripts/ci/opencode_review_surfaces.py +++ b/scripts/ci/opencode_review_surfaces.py @@ -329,6 +329,14 @@ def _language(value: str) -> str: def extract_model_prose(raw_output: str) -> str: """Return the human review body, stripping sentinel and control JSON.""" + if ""): + skipping_control = False + continue + lines.append(line) + slow_result = "\n".join(lines).strip() + + assert fast_result == slow_result == "line one\nline two\n\nline three" + + def test_format_request_changes_keeps_model_prose_and_strips_fake_anchor() -> None: """REQUEST_CHANGES keeps the model walkthrough and never cites workflow:1.""" body = surfaces.format_request_changes_review( diff --git a/tests/test_opencode_rust_coverage_toolchain_contract.py b/tests/test_opencode_rust_coverage_toolchain_contract.py index b1fd4a124e..cc0c49af6f 100644 --- a/tests/test_opencode_rust_coverage_toolchain_contract.py +++ b/tests/test_opencode_rust_coverage_toolchain_contract.py @@ -17,7 +17,10 @@ _REPOSITORY_ROOT / ".github/workflows/opencode-review-dispatch.yml" ) _QUALITY_WORKFLOW_PATH = ( - _REPOSITORY_ROOT / ".github/workflows/opencode-rust-coverage-toolchain-quality-ci.yml" + _REPOSITORY_ROOT + / ".github" + / "workflows" + / "agent-review-runtime-quality-ci.yml" ) _NIM_CONTRACT_PATH = ( _REPOSITORY_ROOT / "tests/test_pr_review_autofix_nvidia_nim_contract.py" @@ -131,7 +134,7 @@ def test_quality_workflow_watched_paths_resolve_to_repository_files() -> None: quality_workflow = _QUALITY_WORKFLOW_PATH.read_text(encoding="utf-8") watched_section = quality_workflow.split(" paths:\n", 1)[1].split( - "\n\npermissions:\n", 1 + "\n\n# PR validation only:", 1 )[0] watched_paths = [ line.strip()[2:].strip('"') @@ -143,7 +146,10 @@ def test_quality_workflow_watched_paths_resolve_to_repository_files() -> None: assert ".github/workflows/opencode-review-dispatch.yml" in watched_paths assert "tests/test_pr_review_autofix_nvidia_nim_contract.py" in watched_paths for relative_path in watched_paths: - assert (_REPOSITORY_ROOT / relative_path).is_file(), relative_path + if any(character in relative_path for character in "*?["): + assert any(_REPOSITORY_ROOT.glob(relative_path)), relative_path + else: + assert (_REPOSITORY_ROOT / relative_path).is_file(), relative_path doctoring = ( _REPOSITORY_ROOT / "docs/doctoring/opencode-rust-coverage-runtime-boundary.md" diff --git a/tests/test_orchestrator_free_sidecar_action_contract.py b/tests/test_orchestrator_free_sidecar_action_contract.py new file mode 100644 index 0000000000..b4948cf7d2 --- /dev/null +++ b/tests/test_orchestrator_free_sidecar_action_contract.py @@ -0,0 +1,30 @@ +"""Contract tests for the central orchestrator/free composite action.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ACTION = ROOT / ".github/actions/orchestrator-free-sidecar/action.yml" + + +def test_action_uses_only_immutable_central_sidecar_source() -> None: + source = ACTION.read_text(encoding="utf-8") + assert "using: composite" in source + assert "repository: ContextualWisdomLab/.github" in source + assert "ref: ${{ github.action_ref }}" in source + assert "persist-credentials: false" in source + assert "contextual_orchestrator_review_sidecar.sh" in source + assert "orchestrator/free" in source + assert "anomalyco/opencode" not in source + assert "integrate.api.nvidia.com" not in source + assert "nvidia/" not in source + + +def test_action_keeps_provider_bootstrap_and_gateway_boundaries_separate() -> None: + source = ACTION.read_text(encoding="utf-8") + assert "CONTEXTUAL_ORCHESTRATOR_REQUIRE_ZDR" in source + assert "ORCHESTRATOR_CATALOG_LIMIT" in source + assert "ORCHESTRATOR_CATALOG_ACCOUNT_CAP" in source + assert "github.action_ref" in source + assert "GITHUB_TOKEN" not in source + assert "OPENROUTER_API_KEY" not in source + assert "NVIDIA_NIM_API_KEY" not in source diff --git a/tests/test_org_required_workflow_scope_contract.py b/tests/test_org_required_workflow_scope_contract.py new file mode 100644 index 0000000000..cdfd2b2c42 --- /dev/null +++ b/tests/test_org_required_workflow_scope_contract.py @@ -0,0 +1,22 @@ +"""Regression contract for organization required-workflow repository scope.""" + +from pathlib import Path + +from scripts.ci.audit_central_required_workflows import EXPECTED_EXCLUSIONS + + +def test_rollout_scope_matches_canonical_exclusions() -> None: + """Rollout prose must name every canonical exclusion and avoid universal claims.""" + rollout = Path("docs/org-required-workflow-rollout.md").read_text(encoding="utf-8") + assert EXPECTED_EXCLUSIONS == {".github", "IRT-bibliography-set", "noema"} + for repository in EXPECTED_EXCLUSIONS: + assert f"`{repository}`" in rollout + assert "all current and future organization\nrepositories inherit" not in rollout + assert "outside that exclusion set inherits the nine central" in rollout + + +def test_doctoring_records_documentation_gate_closed() -> None: + """Doctoring must describe the repaired documentation state, not an open gate.""" + doctoring = Path("docs/doctoring/code-scanning-required-workflow-audit.md").read_text(encoding="utf-8") + assert "## Documentation reconciliation" in doctoring + assert "## Outstanding documentation gate" not in doctoring diff --git a/tests/test_organization_commercial_readiness_loop_import_contract.py b/tests/test_organization_commercial_readiness_loop_import_contract.py index 43c3c71acd..8b3767e169 100644 --- a/tests/test_organization_commercial_readiness_loop_import_contract.py +++ b/tests/test_organization_commercial_readiness_loop_import_contract.py @@ -6,15 +6,19 @@ REPO_ROOT / ".github" / "workflows" - / "organization-commercial-readiness-loop-quality-ci.yml" + / "agent-review-runtime-quality-ci.yml" +) +QUALITY_GATE_WORKFLOW = ( + REPO_ROOT / ".github" / "workflows" / "exact-head-coverage-quality-gate.yml" ) def test_quality_gate_uses_import_stable_test_support() -> None: """Hosted and complete-suite collection must resolve the same helper module.""" source = QUALITY_WORKFLOW.read_text(encoding="utf-8") + gate_source = QUALITY_GATE_WORKFLOW.read_text(encoding="utf-8") - assert "--import-mode=importlib" in source + assert "--import-mode=importlib" in gate_source assert '"organization_commercial_readiness_fixtures.py"' in source assert "tests/organization_commercial_readiness_fixtures.py" not in source assert "--include='scripts/ci/organization_commercial_readiness_loop.py' \\\n -m pytest" not in source diff --git a/tests/test_organization_commercial_readiness_loop_policy.py b/tests/test_organization_commercial_readiness_loop_policy.py index 920f8072f9..ec206499bc 100644 --- a/tests/test_organization_commercial_readiness_loop_policy.py +++ b/tests/test_organization_commercial_readiness_loop_policy.py @@ -149,8 +149,10 @@ def test_workflow_and_doctoring_contracts() -> None: ROOT / ".github/workflows/organization-commercial-readiness-loop.yml" ).read_text() quality = ( - ROOT - / ".github/workflows/organization-commercial-readiness-loop-quality-ci.yml" + ROOT / ".github/workflows/agent-review-runtime-quality-ci.yml" + ).read_text() + quality_gate = ( + ROOT / ".github/workflows/exact-head-coverage-quality-gate.yml" ).read_text() doctoring = ( ROOT / "docs/doctoring/organization-commercial-readiness-loop.md" @@ -167,10 +169,15 @@ def test_workflow_and_doctoring_contracts() -> None: assert "COPILOT_GITHUB_TOKEN" not in workflow_source assert "github.run_number" in workflow_source assert "persist-credentials: false" in workflow_source - assert "--branch" in quality and "--fail-under=100" in quality - assert "--import-mode=importlib" in quality + # The reusable gate remains for its other caller; this suite now reuses the + # existing agent-review quality job's checkout and dependency bootstrap. + assert "commercial_readiness_suite=false" in quality + assert "outputs.commercial_readiness == 'true'" in quality + assert "--include='scripts/ci/organization_commercial_readiness_loop.py'" in quality assert "organization_commercial_readiness_fixtures.py" in quality - assert "github.event.pull_request.head.sha" in quality + assert "--branch" in quality_gate and "--fail-under=100" in quality_gate + assert "--import-mode=importlib" in quality_gate + assert "github.event.pull_request.head.sha" in quality_gate assert "disabled workflow does not hold a lease" in doctoring assert "manual-only, explicitly marked" in doctoring assert "does not make every repository directly writable" in doctoring diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 4b749d93c4..2e733ac9e9 100644 --- a/tests/test_pr_review_autofix_nvidia_nim_contract.py +++ b/tests/test_pr_review_autofix_nvidia_nim_contract.py @@ -17,7 +17,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 = "bb5d439c3fc2fc7b5fcd38533d38f96e1170cd2e" +REVIEW_DISPATCH_BLOB_SHA = "d86497b3f43bebbabbb4f504eb5132cdf3b7b293" def _workflow_text(path: Path) -> str: @@ -25,11 +25,11 @@ def _workflow_text(path: Path) -> str: return path.read_text(encoding="utf-8") -def test_review_fix_caller_runs_once_each_hour() -> None: - """Keep the actionable-review repair caller on the approved hourly cadence.""" +def test_review_fix_caller_keeps_the_github_daily_recovery_slot() -> None: + """Keep the GitHub review repair caller on its distributed daily slot.""" caller = _workflow_text(HOURLY_CALLER_WORKFLOW) - assert 'cron: "23 * * * *"' in caller - assert 'cron: "23 */2 * * *"' not in caller + assert 'cron: "23 7 * * *"' in caller + assert 'cron: "23 * * * *"' not in caller assert "uses: ./.github/workflows/pr-review-fix-scheduler.yml" in caller diff --git a/tests/test_pr_review_conflict_scope_control_files.py b/tests/test_pr_review_conflict_scope_control_files.py index 3fd7f8e81d..31163a6b07 100644 --- a/tests/test_pr_review_conflict_scope_control_files.py +++ b/tests/test_pr_review_conflict_scope_control_files.py @@ -18,7 +18,7 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[1] QUALITY_WORKFLOW = ( - REPOSITORY_ROOT / ".github" / "workflows" / "hourly-nvidia-nim-review-repair.yml" + REPOSITORY_ROOT / ".github" / "workflows" / "agent-review-runtime-quality-ci.yml" ) CONTRACT_PATH = "tests/test_pr_review_conflict_scope_control_files.py" DOCTORING_PATH = "docs/doctoring/conflict-control-evidence-isolation.md" @@ -105,10 +105,10 @@ def test_verify_rejects_external_symlink_resolving_into_repository( def test_control_evidence_contract_cannot_bypass_its_quality_workflow() -> None: - """Keep the security regression and doctoring in both exact-head triggers.""" + """Keep the security regression and doctoring in the exact-head PR trigger.""" workflow = QUALITY_WORKFLOW.read_text(encoding="utf-8") trigger_block = workflow[: workflow.index("\npermissions:")] - assert trigger_block.count(CONTRACT_PATH) == 2 - assert trigger_block.count(DOCTORING_PATH) == 2 + assert trigger_block.count(CONTRACT_PATH) == 1 + assert trigger_block.count(DOCTORING_PATH) == 1 assert CONTRACT_PATH in workflow[workflow.index("python -m compileall -q") :] diff --git a/tests/test_pr_review_fix_hourly_contract.py b/tests/test_pr_review_fix_hourly_contract.py index 63e2c1e7c2..994145b469 100644 --- a/tests/test_pr_review_fix_hourly_contract.py +++ b/tests/test_pr_review_fix_hourly_contract.py @@ -14,7 +14,7 @@ _REUSABLE_WORKFLOW = Path(".github/workflows/pr-review-fix-scheduler.yml") _AUTOFIX_WORKFLOW = Path(".github/workflows/pr-review-autofix.yml") _CONSOLIDATED_CALLER = Path(".github/workflows/hourly-review-repair.yml") -_CONTRACT_WORKFLOW = Path(".github/workflows/hourly-nvidia-nim-review-repair.yml") +_CONTRACT_WORKFLOW = Path(".github/workflows/agent-review-runtime-quality-ci.yml") _AUTOMATION_GUIDE = Path("docs/automation/hourly-review-repair.md") @@ -49,8 +49,8 @@ def _current_head_change_request(body: str) -> dict[str, object]: } -def test_clearfolio_caller_runs_once_each_hour() -> None: - """Clearfolio receives the requested hourly bounded repair heartbeat. +def test_clearfolio_caller_runs_once_each_day() -> None: + """Clearfolio receives one bounded daily missed-event recovery. The consolidated caller resolves per-repository parameters through a ``github.event.schedule`` lookup table (see @@ -60,7 +60,7 @@ def test_clearfolio_caller_runs_once_each_hour() -> None: """ text = _read(_CONSOLIDATED_CALLER) - assert 'cron: "23 * * * *"' in text + assert 'cron: "23 7 * * *"' in text assert "uses: ./.github/workflows/pr-review-fix-scheduler.yml" in text assert '"target_repository":"ContextualWisdomLab/clearfolio"' in text assert '"base_branch":"main"' in text @@ -195,12 +195,43 @@ def test_scheduler_validates_dispatch_authority_before_credentials() -> None: check=False, ).returncode == 0 + # ALLOWED_DISPATCH_ACTOR is a comma-separated allowlist shared with the two + # dispatch workflows; every listed identity passes when actor and sender + # both equal it, whitespace around commas tolerated. + allowlist = "github-actions[bot], opencode-agent[bot]" + for identity in ("github-actions[bot]", "opencode-agent[bot]"): + assert subprocess.run( + ["bash"], + input=shell, + text=True, + env={ + **base_env, + "ALLOWED_DISPATCH_ACTOR": allowlist, + "DISPATCH_ACTOR": identity, + "DISPATCH_SENDER": identity, + }, + check=False, + ).returncode == 0 + for override in ( {"DISPATCH_SENDER": "untrusted"}, {"DISPATCH_ACTOR": "untrusted"}, {"TARGET_REPOSITORY": "ContextualWisdomLab/unapproved"}, {"ALLOWED_DISPATCH_ACTOR": ""}, {"ALLOWED_TARGET_REPOSITORIES": ""}, + # A listed allowlist still rejects an unlisted identity. + { + "ALLOWED_DISPATCH_ACTOR": allowlist, + "DISPATCH_ACTOR": "untrusted", + "DISPATCH_SENDER": "untrusted", + }, + # Actor and sender must be the SAME listed identity, not each some + # listed identity. + { + "ALLOWED_DISPATCH_ACTOR": allowlist, + "DISPATCH_ACTOR": "opencode-agent[bot]", + "DISPATCH_SENDER": "github-actions[bot]", + }, ): assert subprocess.run( ["bash"], @@ -258,6 +289,17 @@ def test_review_fix_scheduler_remains_bounded_and_single_flight() -> None: assert "cancel-in-progress: false" in caller +def test_product_recovery_admits_at_most_one_workflow_each_hour() -> None: + """Native events own normal progress; recovery cron entries stay daily and spread.""" + caller = _read(_CONSOLIDATED_CALLER) + cron_lines = [line.strip() for line in caller.splitlines() if "- cron:" in line] + hours = [line.split()[3] for line in cron_lines] + + assert len(cron_lines) == 17 + assert all(" * * *" in line and "* * * *" not in line for line in cron_lines) + assert len(hours) == len(set(hours)) + + def test_contract_workflow_tracks_the_product_caller() -> None: """Changes to the consolidated product caller always rerun the focused gate.""" text = _read(_CONTRACT_WORKFLOW) diff --git a/tests/test_pr_review_fix_scheduler.py b/tests/test_pr_review_fix_scheduler.py index 32c20738ea..6b9bd91e0c 100644 --- a/tests/test_pr_review_fix_scheduler.py +++ b/tests/test_pr_review_fix_scheduler.py @@ -488,7 +488,7 @@ def test_process_queue_dispatches_same_repo_current_head(monkeypatch, capsys): pr = make_pr() calls = [] - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr]) + monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs, **kwargs: [pr]) monkeypatch.setattr(fix, "needs_autofix", lambda pr: (True, ("current-head OpenCode requested changes",))) monkeypatch.setattr(fix, "issue_comments", lambda repo, number: []) monkeypatch.setattr(fix, "prepare_autofix_slot", lambda *_args, **_kwargs: False) @@ -517,6 +517,62 @@ def test_process_queue_dispatches_same_repo_current_head(monkeypatch, capsys): assert payload["autofix_dispatches"] == 1 +def test_process_queue_rotates_a_fifty_pr_window_and_stops_after_dispatch( + monkeypatch, capsys +): + """One run deeply inspects at most one window and stops after its dispatch.""" + prs = [make_pr(number=1), make_pr(number=2)] + fetch_calls = [] + context_calls = [] + comment_calls = [] + + def fetch(repo, max_prs, *, offset=0, window_size=None): + fetch_calls.append((repo, max_prs, offset, window_size)) + return prs + + monkeypatch.setattr(fix, "fetch_open_prs", fetch) + monkeypatch.setattr( + fix, + "complete_paginated_pr_contexts", + lambda repo, pr: context_calls.append(pr["number"]), + ) + monkeypatch.setattr( + fix, + "issue_comments", + lambda repo, number: comment_calls.append(number) or [], + ) + monkeypatch.setattr( + fix, + "needs_autofix", + lambda pr: (True, ("current-head OpenCode requested changes",)), + ) + monkeypatch.setattr(fix, "prepare_autofix_slot", lambda *_args, **_kwargs: False) + monkeypatch.setattr(fix, "dispatch_autofix", lambda *_args, **_kwargs: None) + monkeypatch.setattr(fix, "create_fix_marker", lambda *_args, **_kwargs: None) + + assert fix.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--max-prs", + "200", + "--scan-window-size", + "50", + "--rotation-seed", + "3", + ] + ) == 0 + + assert fetch_calls == [("owner/repo", 200, 150, 50)] + assert context_calls == [1] + assert comment_calls == [1] + payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert payload["inspected"] == 1 + assert payload["autofix_dispatches"] == 1 + + def test_autofix_context_filters_outdated_threads_and_renders_checks(): """The context helper filters stale threads and renders compact checks.""" assert context.repo_parts("owner/repo") == ("owner", "repo") @@ -1125,34 +1181,26 @@ def fail_once(argv, *, stdin=None): def test_process_queue_defers_prs_whose_comment_fetch_failed(monkeypatch, capsys): """A single failing comment fetch defers that PR instead of erroring.""" pr = make_pr() - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr]) + monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs, **kwargs: [pr]) monkeypatch.setattr(fix, "needs_autofix", lambda pr: (True, ("reason",))) def failing_issue_comments(repo, number): raise RuntimeError("gh: API rate limit exceeded for installation ID 1") monkeypatch.setattr(fix, "issue_comments", failing_issue_comments) - inspect_calls = [] - monkeypatch.setattr( - fix, - "inspect_pr", - lambda repo, pr, args, **kwargs: inspect_calls.append(kwargs) or ("dispatch", ("reason",)), - ) - assert fix.main(["--repo", "owner/repo", "--base-branch", "main", "--dry-run"]) == 0 - assert inspect_calls == [] payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) assert payload["autofix_dispatches"] == 0 assert payload["decisions"][0]["action"] == "wait" assert "deferring to next scheduled pass" in payload["decisions"][0]["reasons"][0] -def test_process_queue_concurrent_fetch_defers_only_the_failing_pr(monkeypatch, capsys): - """The concurrent comment-fetch path defers only the PR whose fetch failed.""" +def test_process_queue_sequential_fetch_defers_only_the_failing_pr(monkeypatch, capsys): + """Sequential comment lookup defers one PR and continues to the next.""" pr1 = make_pr(number=1) pr2 = make_pr(number=2) - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr1, pr2]) + monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs, **kwargs: [pr1, pr2]) monkeypatch.setattr(fix, "needs_autofix", lambda pr: (True, ("reason",))) def flaky_issue_comments(repo, number): @@ -1161,17 +1209,18 @@ def flaky_issue_comments(repo, number): return [] monkeypatch.setattr(fix, "issue_comments", flaky_issue_comments) - inspect_calls = [] - - def fake_inspect_pr(repo, pr, args, **kwargs): - inspect_calls.append((pr["number"], kwargs.get("comments"))) - return "dispatch", ("reason",) - - monkeypatch.setattr(fix, "inspect_pr", fake_inspect_pr) + dispatched = [] + monkeypatch.setattr(fix, "prepare_autofix_slot", lambda *_args, **_kwargs: False) + monkeypatch.setattr( + fix, + "dispatch_autofix", + lambda repo, pr, **kwargs: dispatched.append(pr["number"]), + ) + monkeypatch.setattr(fix, "create_fix_marker", lambda *_args, **_kwargs: None) assert fix.main(["--repo", "owner/repo", "--base-branch", "main", "--dry-run", "--max-dispatches", "2"]) == 0 - assert inspect_calls == [(2, [])] + assert dispatched == [2] payload = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) decisions_by_pr = {d["pr"]: d for d in payload["decisions"]} assert decisions_by_pr[1]["action"] == "wait" @@ -1324,7 +1373,7 @@ def test_inspect_pr_dispatches_conflict_resolution(monkeypatch): def test_process_queue_includes_conflict_resolution_candidates(monkeypatch, capsys): """The queue pre-filter fetches comments for approved conflicting PRs too.""" pr = _approved_dirty_pr(baseRefName="feature-base") - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr]) + monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs, **kwargs: [pr]) monkeypatch.setattr(fix, "issue_comments", lambda repo, number: []) monkeypatch.setattr(fix, "prepare_autofix_slot", lambda *_args, **_kwargs: False) monkeypatch.setattr( @@ -1387,12 +1436,13 @@ def test_fix_inspect_skip_wait_and_error_paths(monkeypatch): pr1 = make_pr(number=1) pr2 = make_pr(number=2) - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr1, pr2]) + monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs, **kwargs: [pr1, pr2]) monkeypatch.setattr(fix, "inspect_pr", lambda repo, pr, args, **kwargs: ("dispatch", ("reason",))) payload_lines = [] monkeypatch.setattr("builtins.print", lambda *parts, **kwargs: payload_lines.append(" ".join(map(str, parts)))) assert fix.process_queue(args) == 0 - assert "autofix dispatch limit reached" in payload_lines[-1] + assert '"inspected": 1' in payload_lines[-1] + assert "autofix dispatch limit reached" not in payload_lines[-1] monkeypatch.setattr(fix, "fetch_pr", lambda repo, number: [make_pr(number=number)]) monkeypatch.setattr(fix, "inspect_pr", lambda repo, pr, args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom"))) @@ -1417,6 +1467,8 @@ def test_fix_parse_args_and_self_test(monkeypatch): ["--repo", "owner/repo"], ["--repo", "owner/repo", "--base-branch", "main", "--pr-number", "-1"], ["--repo", "owner/repo", "--base-branch", "main", "--max-prs", "0"], + ["--repo", "owner/repo", "--base-branch", "main", "--scan-window-size", "0"], + ["--repo", "owner/repo", "--base-branch", "main", "--rotation-seed", "-1"], ["--repo", "owner/repo", "--base-branch", "main", "--max-dispatches", "0"], ["--repo", "owner/repo", "--base-branch", "main", "--retry-hours", "0"], ["--repo", "owner/repo", "--base-branch", "main", "--autofix-repository", "bad"], diff --git a/tests/test_pr_review_fix_scheduler_coverage.py b/tests/test_pr_review_fix_scheduler_coverage.py index d799567143..09645f3a26 100644 --- a/tests/test_pr_review_fix_scheduler_coverage.py +++ b/tests/test_pr_review_fix_scheduler_coverage.py @@ -62,7 +62,7 @@ def make_pr(number=1, **kwargs): monkeypatch.setattr( fix, "fetch_open_prs", - lambda repo, max_prs: [pr1, pr2, pr3], + lambda repo, max_prs, **kwargs: [pr1, pr2, pr3], ) monkeypatch.setattr( fix, @@ -90,7 +90,7 @@ def make_pr(number=1, **kwargs): args = fix.parse_args(["--repo", "owner/repo", "--base-branch", "main"]) pr1 = make_pr(number=1) pr2 = make_pr(number=2) - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr1, pr2]) + monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs, **kwargs: [pr1, pr2]) monkeypatch.setattr(fix, "needs_autofix", lambda pr: (True, ("reason",))) def raise_error(repo, number): diff --git a/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py b/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py index af1dfd71ef..da634bcfe4 100644 --- a/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py +++ b/tests/test_pr_review_fix_scheduler_direct_rca_regressions.py @@ -156,7 +156,7 @@ def dispatch(repo: str, candidate: dict[str, Any], **kwargs: Any) -> None: order.append("dispatch") captured.update(kwargs) - monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs: [pr]) + monkeypatch.setattr(fix, "fetch_open_prs", lambda repo, max_prs, **kwargs: [pr]) monkeypatch.setattr(fix, "fetch_pr", lambda repo, number: [pr]) monkeypatch.setattr(fix, "complete_paginated_pr_contexts", complete_pages) monkeypatch.setattr(fix, "issue_comments", lambda repo, number: []) @@ -197,7 +197,7 @@ def complete_pages(repo: str, candidate: dict[str, Any]) -> None: monkeypatch.setattr( fix, "fetch_open_prs", - lambda repo, max_prs: [blocked, repairable], + lambda repo, max_prs, **kwargs: [blocked, repairable], ) monkeypatch.setattr(fix, "complete_paginated_pr_contexts", complete_pages) monkeypatch.setattr(fix, "issue_comments", lambda repo, number: []) @@ -249,7 +249,7 @@ def complete_pages(repo: str, candidate: dict[str, Any]) -> None: monkeypatch.setattr( fix, "fetch_open_prs", - lambda repo, max_prs: [out_of_scope, in_scope], + lambda repo, max_prs, **kwargs: [out_of_scope, in_scope], ) monkeypatch.setattr(fix, "complete_paginated_pr_contexts", complete_pages) monkeypatch.setattr(fix, "issue_comments", lambda repo, number: []) diff --git a/tests/test_pr_review_fix_scheduler_source_pin.py b/tests/test_pr_review_fix_scheduler_source_pin.py index 7958ba5163..0f9e0adb1c 100644 --- a/tests/test_pr_review_fix_scheduler_source_pin.py +++ b/tests/test_pr_review_fix_scheduler_source_pin.py @@ -2,6 +2,10 @@ from __future__ import annotations +from tests.test_required_workflow_queue_contract import ( + workflow_level_cancels_in_progress, +) + from pathlib import Path @@ -88,7 +92,7 @@ def test_reusable_scheduler_retains_least_privilege_and_bounded_dispatch() -> No assert "pull-requests: write" not in workflow assert "MAX_DISPATCHES:" in workflow assert "RETRY_HOURS:" in workflow - assert "cancel-in-progress: true" in workflow + assert workflow_level_cancels_in_progress(workflow) def test_reusable_scheduler_bounds_both_oidc_exchange_requests() -> None: diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 8b5ddfcbce..ba47b89c8d 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -176,6 +176,125 @@ def inspect(pr, **overrides): return sched.inspect_pr("owner/repo", pr, **kwargs) +def test_inspect_pr_closes_only_fresh_non_draft_empty_pull_request(monkeypatch): + head_sha = "a" * 40 + candidate = make_pr( + headRefOid=head_sha, + files={"totalCount": 0, "nodes": []}, + ) + calls = [] + monkeypatch.setattr( + sched, + "_fresh_open_pr_for_cancellation", + lambda _repo, _number: { + "draft": False, + "changed_files": 0, + "head": {"sha": head_sha}, + }, + ) + monkeypatch.setattr(sched, "run", lambda args: calls.append(args) or "") + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) + + decision = inspect(candidate, dry_run=False) + + assert decision.action == "close_empty" + assert sched.contract_decision(decision) == "NO_ACTION" + assert calls[-1] == ["gh", "pr", "close", "1", "--repo", "owner/repo"] + + +def test_inspect_pr_classifies_empty_pull_request_without_closing_in_dry_run(monkeypatch): + head_sha = "a" * 40 + candidate = make_pr( + headRefOid=head_sha, + files={"totalCount": 0, "nodes": []}, + ) + calls = [] + monkeypatch.setattr( + sched, + "_fresh_open_pr_for_cancellation", + lambda _repo, _number: { + "draft": False, + "changed_files": 0, + "head": {"sha": head_sha}, + }, + ) + monkeypatch.setattr(sched, "run", lambda args: calls.append(args) or "") + + decision = inspect(candidate, dry_run=True) + + assert decision.action == "close_empty" + assert calls == [] + + +def test_inspect_pr_closes_empty_pull_request_even_if_the_comment_call_fails(monkeypatch): + head_sha = "a" * 40 + candidate = make_pr( + headRefOid=head_sha, + files={"totalCount": 0, "nodes": []}, + ) + calls = [] + + def fake_run(args): + if args[2] == "comment": + raise RuntimeError("comment API failure") + calls.append(args) + return "" + + monkeypatch.setattr( + sched, + "_fresh_open_pr_for_cancellation", + lambda _repo, _number: { + "draft": False, + "changed_files": 0, + "head": {"sha": head_sha}, + }, + ) + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr( + sched, + "recover_current_head_startup_failures", + lambda repo, pr, *, dry_run: [], + ) + + decision = inspect(candidate, dry_run=False) + + assert decision.action == "close_empty" + assert calls == [["gh", "pr", "close", "1", "--repo", "owner/repo"]] + + +@pytest.mark.parametrize( + "fresh", + ( + {"draft": True, "changed_files": 0, "head": {"sha": "a" * 40}}, + {"draft": False, "changed_files": 1, "head": {"sha": "a" * 40}}, + {"draft": False, "changed_files": None, "head": {"sha": "a" * 40}}, + {"draft": False, "changed_files": 0, "head": {"sha": "b" * 40}}, + ), +) +def test_inspect_pr_does_not_close_stale_or_ineligible_empty_candidate( + monkeypatch, fresh +): + candidate = make_pr( + headRefOid="a" * 40, + files={"totalCount": 0, "nodes": []}, + ) + calls = [] + monkeypatch.setattr( + sched, "_fresh_open_pr_for_cancellation", lambda _repo, _number: fresh + ) + monkeypatch.setattr(sched, "run", lambda args: calls.append(args) or "") + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) + + decision = inspect(candidate, dry_run=False) + + assert decision.action in {"skip", "wait"} + assert calls == [] + + def last_push_restamp_candidate(**overrides): value = make_pr( mergeStateStatus="BLOCKED", @@ -338,6 +457,49 @@ def test_fetch_open_prs_zero_limit_skips_graphql(monkeypatch): assert calls == [("owner/repo", [])] +def test_rotating_pr_window_is_bounded_and_wraps_over_actual_results(): + """A deterministic offset rotates bounded windows without empty tail slots.""" + prs = [{"number": number} for number in range(1, 121)] + + assert sched.rotating_pr_window(prs, offset=0, window_size=50) == prs[:50] + assert sched.rotating_pr_window(prs, offset=50, window_size=50) == prs[50:100] + assert sched.rotating_pr_window(prs, offset=100, window_size=50) == prs[100:120] + assert sched.rotating_pr_window(prs, offset=150, window_size=50) == prs[:50] + assert sched.rotating_pr_window(prs, offset=0, window_size=None) == prs + assert sched.rotating_pr_window([], offset=0, window_size=50) == [] + with pytest.raises(ValueError, match="PR window offset must be non-negative and size must be positive"): + sched.rotating_pr_window(prs, offset=-1, window_size=50) + with pytest.raises(ValueError, match="PR window offset must be non-negative and size must be positive"): + sched.rotating_pr_window(prs, offset=0, window_size=0) + + +def test_rest_fallback_hydrates_only_the_selected_rotating_window(monkeypatch): + """REST discovery may reach 120 PRs but hydrates no more than 50 of them.""" + pages = { + 1: [{"number": number} for number in range(1, 101)], + 2: [{"number": number} for number in range(101, 121)], + } + hydrated = [] + + def fake_api(path): + page = int(path.rsplit("page=", 1)[1]) + return pages[page] + + def fake_rest_pr_node(repo, pr): + hydrated.append(pr["number"]) + return {"number": pr["number"]} + + monkeypatch.setattr(sched, "gh_api_json", fake_api) + monkeypatch.setattr(sched, "rest_pr_node", fake_rest_pr_node) + + result = sched.fetch_open_prs_rest( + "owner/repo", 120, offset=50, window_size=50 + ) + + assert [pr["number"] for pr in result] == list(range(51, 101)) + assert sorted(hydrated) == list(range(51, 101)) + + def test_fetch_open_prs_caps_page_size_to_avoid_graphql_resource_limits(monkeypatch): seen = [] @@ -894,6 +1056,176 @@ def fake_run(args, stdin=None): assert len(calls) == 1 +def test_is_rate_limited_error_matches_only_the_shared_installation_signature(): + assert sched.is_rate_limited_error( + RuntimeError( + "Command failed (1): gh api graphql\n" + "gh: API rate limit exceeded for installation ID 141441800. (HTTP 403)" + ) + ) + # GitHub's own casing varies by surface; the check must not be case-sensitive. + assert sched.is_rate_limited_error(RuntimeError("gh: api rate limit EXCEEDED for installation ID 1")) + assert not sched.is_rate_limited_error(RuntimeError("Resource not accessible by integration")) + assert not sched.is_rate_limited_error(RuntimeError("Command failed (1): gh api graphql\ngh: HTTP 502")) + assert not sched.is_rate_limited_error( + RuntimeError("gh: You have exceeded a secondary rate limit. Please wait a few minutes.") + ) + + +def test_rate_limit_retry_delay_seconds_uses_the_reported_reset_time(monkeypatch): + calls = [] + + def fake_run(args, stdin=None): + calls.append(args) + return json.dumps({"resources": {"core": {"remaining": 0, "reset": 1_000_050}}}) + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "time", lambda: 1_000_000) + + assert sched.rate_limit_retry_delay_seconds("core", 1) == 55 + assert calls == [["gh", "api", "rate_limit"]] + + +def test_rate_limit_retry_delay_seconds_caps_a_long_reset_wait(monkeypatch): + def fake_run(args, stdin=None): + return json.dumps({"resources": {"graphql": {"remaining": 0, "reset": 1_010_000}}}) + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "time", lambda: 1_000_000) + + assert sched.rate_limit_retry_delay_seconds("graphql", 1) == sched.GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS + + +def test_rate_limit_retry_delay_seconds_falls_back_when_bucket_is_not_empty(monkeypatch): + def fake_run(args, stdin=None): + return json.dumps({"resources": {"core": {"remaining": 42, "reset": 1_000_050}}}) + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "time", lambda: 1_000_000) + + assert sched.rate_limit_retry_delay_seconds("core", 2) == 2 + + +def test_rate_limit_retry_delay_seconds_falls_back_when_reset_is_missing(monkeypatch): + def fake_run(args, stdin=None): + return json.dumps({"resources": {"core": {"remaining": 0}}}) + + monkeypatch.setattr(sched, "run", fake_run) + + assert sched.rate_limit_retry_delay_seconds("core", 3) == 4 + + +def test_rate_limit_retry_delay_seconds_falls_back_when_reset_is_in_the_past(monkeypatch): + def fake_run(args, stdin=None): + return json.dumps({"resources": {"core": {"remaining": 0, "reset": 999_990}}}) + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "time", lambda: 1_000_000) + + assert sched.rate_limit_retry_delay_seconds("core", 1) == 1 + + +def test_rate_limit_retry_delay_seconds_falls_back_on_malformed_payload(monkeypatch): + def fake_run(args, stdin=None): + return json.dumps([]) + + monkeypatch.setattr(sched, "run", fake_run) + + assert sched.rate_limit_retry_delay_seconds("core", 2) == 2 + + +def test_rate_limit_retry_delay_seconds_falls_back_when_lookup_fails(monkeypatch): + def fake_run(args, stdin=None): + raise RuntimeError("Command failed (1): gh api rate_limit\nHTTP 500") + + monkeypatch.setattr(sched, "run", fake_run) + + assert sched.rate_limit_retry_delay_seconds("core", 7) == sched.GITHUB_API_RATE_LIMIT_RETRY_CAP_SECONDS + + +def test_gh_graphql_retries_rate_limited_errors_using_the_reset_time(monkeypatch): + calls = [] + sleeps = [] + reset_epoch = 1_700_000_100 + + def fake_run(args, stdin=None): + calls.append(args) + if len(args) >= 3 and args[2] == "graphql": + if len(calls) == 1: + raise RuntimeError( + "Command failed (1): gh api graphql\n" + "gh: API rate limit exceeded for installation ID 141441800. (HTTP 403)" + ) + return '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false}}}}}' + assert args == ["gh", "api", "rate_limit"] + return json.dumps({"resources": {"graphql": {"remaining": 0, "reset": reset_epoch}}}) + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "time", lambda: reset_epoch - 10) + monkeypatch.setattr(sched.time, "sleep", lambda seconds: sleeps.append(seconds)) + + payload = sched.gh_graphql("query", owner="owner", name="repo", pageSize=100) + + assert payload["data"]["repository"]["pullRequests"]["nodes"] == [] + assert sleeps == [15] + + +def test_gh_api_json_retries_rate_limited_errors_then_succeeds(monkeypatch): + calls = [] + sleeps = [] + + def fake_run(args, stdin=None): + calls.append(args) + if args == ["gh", "api", "repos/owner/repo/pulls/1"]: + if len(calls) == 1: + raise RuntimeError( + "Command failed (1): gh api repos/owner/repo/pulls/1\n" + "gh: API rate limit exceeded for installation ID 141441800. (HTTP 403)" + ) + return '{"number": 1}' + assert args == ["gh", "api", "rate_limit"] + # The reset lookup itself failing must not be fatal: the retry falls + # back to capped exponential backoff instead of raising. + raise RuntimeError("Command failed (1): gh api rate_limit\nHTTP 500") + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "sleep", lambda seconds: sleeps.append(seconds)) + + assert sched.gh_api_json("repos/owner/repo/pulls/1") == {"number": 1} + assert sleeps == [1] + + +def test_gh_api_json_retries_transient_errors(monkeypatch): + calls = [] + sleeps = [] + + def fake_run(args, stdin=None): + calls.append(args) + if len(calls) == 1: + raise RuntimeError("Command failed (1): gh api repos/owner/repo/pulls/1\ngh: HTTP 502") + return '{"number": 1}' + + monkeypatch.setattr(sched, "run", fake_run) + monkeypatch.setattr(sched.time, "sleep", lambda seconds: sleeps.append(seconds)) + + assert sched.gh_api_json("repos/owner/repo/pulls/1") == {"number": 1} + assert sleeps == [1] + + +def test_gh_api_json_does_not_retry_non_transient_errors(monkeypatch): + calls = [] + + def fake_run(args, stdin=None): + calls.append(args) + raise RuntimeError("Command failed (1): gh api repos/owner/repo/pulls/1\ngh: HTTP 404") + + monkeypatch.setattr(sched, "run", fake_run) + + with pytest.raises(RuntimeError, match="HTTP 404"): + sched.gh_api_json("repos/owner/repo/pulls/1") + assert calls == [["gh", "api", "repos/owner/repo/pulls/1"]] + + def test_rest_mergeable_state_helpers(monkeypatch): calls = [] @@ -1217,7 +1549,7 @@ def deny_graphql(*args, **kwargs): raise RuntimeError("gh: Resource not accessible by integration") monkeypatch.setattr(sched, "gh_graphql", deny_graphql) - monkeypatch.setattr(sched, "fetch_open_prs_rest", lambda repo, max_prs: [{"repo": repo, "max": max_prs}]) + monkeypatch.setattr(sched, "fetch_open_prs_rest", lambda repo, max_prs, **kwargs: [{"repo": repo, "max": max_prs}]) assert sched.fetch_open_prs("owner/repo", 5) == [{"repo": "owner/repo", "max": 5}] @@ -1248,7 +1580,7 @@ def fail_graphql(*args, **kwargs): raise RuntimeError("Command failed (1): gh api graphql\ngh: HTTP 504") monkeypatch.setattr(sched, "gh_graphql", fail_graphql) - monkeypatch.setattr(sched, "fetch_open_prs_rest", lambda repo, max_prs: [{"repo": repo, "max": max_prs}]) + monkeypatch.setattr(sched, "fetch_open_prs_rest", lambda repo, max_prs, **kwargs: [{"repo": repo, "max": max_prs}]) monkeypatch.setattr(sched, "fetch_pr_rest", lambda repo, number: [{"repo": repo, "number": number}]) assert sched.fetch_open_prs("owner/repo", 1) == [{"repo": "owner/repo", "max": 1}] @@ -1990,6 +2322,7 @@ def test_dispatch_opencode_review_falls_back_to_bounded_discovery(monkeypatch): head_sha = "a" * 40 pr = make_pr(headRefOid=head_sha, baseRefOid="b" * 40) + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [pr]) result = sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False) assert result == "dispatched" @@ -4206,6 +4539,7 @@ def fake_run(args, stdin=None): sched.merge_pr("owner/repo", pr, dry_run=False) sched.disable_auto_merge("owner/repo", pr, dry_run=False) sched.update_branch("owner/repo", pr, dry_run=False) + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [pr]) sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", pr, dry_run=False) sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False) assert calls[0][:4] == ["gh", "pr", "merge", "1"] @@ -4328,6 +4662,33 @@ def fake_run(args, stdin=None): assert calls[-1][0][-2:] == ["--input", "-"] +def test_startup_failure_restamp_reuses_guarded_same_tree_path(monkeypatch): + calls = [] + monkeypatch.setattr( + sched, + "restamp_pr_head", + lambda repo, pr, **kwargs: calls.append((repo, pr["number"], kwargs)) or "b" * 40, + ) + + assert ( + sched.restamp_pr_head_after_startup_failure( + "owner/repo", make_pr(number=7), dry_run=False + ) + == "b" * 40 + ) + assert calls == [ + ( + "owner/repo", + 7, + { + "dry_run": False, + "action": "startup-failure-head-refresh", + "message": sched.STARTUP_FAILURE_RESTAMP_MESSAGE, + }, + ) + ] + + def test_head_mutations_refuse_the_workflow_github_token(monkeypatch): """A GITHUB_TOKEN head mutation would deadlock the PR, so it must be refused. @@ -4489,6 +4850,7 @@ def fake_run_with_env(args, *, stdin=None, env=None): monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-actions-token") pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [pr]) sched.rerun_actions_job("owner/repo", "101", dry_run=False, action="rerun-opencode-review") sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", pr, dry_run=False) sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False) @@ -4536,40 +4898,378 @@ def fake_run_with_env(args, *, stdin=None, env=None): ] -def test_missing_evidence_dispatch_uses_central_required_workflow_repository(monkeypatch): +def test_recover_current_head_startup_failures_restamps_only_latest_failed_workflows(monkeypatch): calls = [] head_sha = "a" * 40 - base_sha = "b" * 40 - - def fake_run_with_env(args, *, stdin=None, env=None): - calls.append((args, stdin, None if env is None else env.get("GH_TOKEN"))) - if "/actions/runs" in " ".join(args): - return '{"workflow_runs": []}' - return "" - - monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) - monkeypatch.setenv("GITHUB_ACTIONS", "true") - monkeypatch.setenv("GH_TOKEN", "opencode-app-token") - monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-actions-token") - monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") - monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REF", "main") - pr = make_pr( - baseRefName="develop", - baseRefOid=base_sha, - headRefOid=head_sha, - statusCheckRollup={ - "contexts": { - "nodes": [ - opencode_check( - details_url="https://github.com/owner/repo/actions/runs/42/job/101" - ) + def fake_read(args): + if args == ["gh", "api", "repos/owner/repo/pulls/1", "--jq", ".head.sha"]: + return head_sha + assert args == [ + "gh", + "api", + "--method", + "GET", + "repos/owner/repo/actions/runs", + "-f", + f"head_sha={head_sha}", + "-F", + "per_page=100", + ] + return json.dumps( + { + "workflow_runs": [ + { + "id": 90, + "workflow_id": 10, + "name": "Security Scan", + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "run_attempt": 1, + "created_at": "2026-09-04T01:00:00Z", + }, + { + "id": 91, + "workflow_id": 11, + "name": "SAST Semgrep", + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "run_attempt": 2, + "created_at": "2026-09-04T01:01:00Z", + }, + { + "id": 92, + "workflow_id": 12, + "name": "CodeQL PR", + "path": ".github/workflows/codeql-pr.yml", + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "run_attempt": 1, + "created_at": "2026-09-04T01:02:00Z", + }, + { + "id": 93, + "workflow_id": 13, + "name": "Dependency Review", + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "run_attempt": 1, + "created_at": "2026-09-04T01:03:00Z", + }, + { + "id": 94, + "workflow_id": 13, + "name": "Dependency Review", + "event": "pull_request", + "head_sha": head_sha, + "status": "queued", + "conclusion": None, + "run_attempt": 1, + "created_at": "2026-09-04T01:04:00Z", + }, + { + "id": 95, + "workflow_id": 14, + "name": "Weekly Full-Tree Scan", + "event": "schedule", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "run_attempt": 1, + "created_at": "2026-09-04T01:05:00Z", + }, + { + "id": 96, + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "run_attempt": 1, + "created_at": "2026-09-04T01:06:00Z", + }, + { + "id": 89, + "workflow_id": 13, + "name": "Dependency Review", + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "run_attempt": 1, + "created_at": "2026-09-04T01:00:30Z", + }, ] } - }, - ) - sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", pr, dry_run=False) - sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False) + ) + + monkeypatch.setattr(sched, "run_github_read", fake_read) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: True) + monkeypatch.setattr( + sched, + "restamp_pr_head_after_startup_failure", + lambda repo, pr, **kwargs: calls.append((repo, pr["headRefOid"], kwargs)), + ) + + recovered = sched.recover_current_head_startup_failures( + "owner/repo", make_pr(headRefOid=head_sha), dry_run=False + ) + + assert recovered == [90, 91, 92] + assert calls == [ + ( + "owner/repo", + head_sha, + {"dry_run": False}, + ) + ] + + +def test_recover_current_head_startup_failures_does_not_restamp_twice(monkeypatch): + head_sha = "a" * 40 + pr = make_pr(headRefOid=head_sha) + pr["commits"]["nodes"][0]["commit"]["messageHeadline"] = ( + sched.STARTUP_FAILURE_RESTAMP_MESSAGE + ) + monkeypatch.setattr( + sched, + "run_github_read", + lambda _args: json.dumps( + { + "workflow_runs": [ + { + "id": 90, + "workflow_id": 10, + "name": "Security Scan", + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "created_at": "2026-09-04T01:00:00Z", + } + ] + } + ), + ) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: True) + monkeypatch.setattr( + sched, + "restamp_pr_head_after_startup_failure", + lambda *_args, **_kwargs: pytest.fail("a recovery restamp must not repeat"), + ) + + assert sched.recover_current_head_startup_failures( + "owner/repo", pr, dry_run=False + ) == [] + + +@pytest.mark.parametrize( + "workflow_metadata", + ( + {"workflow_id": 12, "name": "CodeQL PR", "path": ".github/workflows/codeql-pr.yml"}, + {"workflow_id": 12, "name": "Renamed CodeQL", "path": ".github/workflows/codeql-pr.yml"}, + {"workflow_id": 12, "name": "CodeQL PR"}, + ), +) +def test_recover_current_head_startup_failures_restamps_codeql_alone( + monkeypatch, workflow_metadata +): + head_sha = "a" * 40 + restamps = [] + run = { + "id": 92, + "event": "pull_request", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "created_at": "2026-09-04T01:02:00Z", + **workflow_metadata, + } + monkeypatch.setattr( + sched, + "run_github_read", + lambda _args: json.dumps({"workflow_runs": [run]}), + ) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: True) + monkeypatch.setattr( + sched, + "restamp_pr_head_after_startup_failure", + lambda repo, pr, **kwargs: restamps.append((repo, pr["headRefOid"], kwargs)), + ) + + recovered = sched.recover_current_head_startup_failures( + "owner/repo", make_pr(headRefOid=head_sha), dry_run=False + ) + + assert recovered == [92] + assert restamps == [("owner/repo", head_sha, {"dry_run": False})] + + +def test_recover_current_head_startup_failures_ignores_runs_with_jobs(monkeypatch): + head_sha = "a" * 40 + run = { + "id": 92, + "workflow_id": 12, + "name": "Required OpenCode Review", + "event": "pull_request_target", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "created_at": "2026-09-04T01:02:00Z", + } + monkeypatch.setattr( + sched, + "run_github_read", + lambda _args: json.dumps({"workflow_runs": [run]}), + ) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: False) + monkeypatch.setattr( + sched, + "restamp_pr_head_after_startup_failure", + lambda *_args, **_kwargs: pytest.fail("a run with jobs is not a pre-job failure"), + ) + + assert sched.recover_current_head_startup_failures( + "owner/repo", make_pr(headRefOid=head_sha), dry_run=False + ) == [] + + +def test_actions_run_has_no_jobs_checks_every_attempt(monkeypatch): + calls = [] + monkeypatch.setattr( + sched, + "run_github_read", + lambda args: calls.append(args) or json.dumps({"total_count": 0, "jobs": []}), + ) + + assert sched.actions_run_has_no_jobs("owner/repo", 92) + assert calls == [[ + "gh", "api", "--method", "GET", "repos/owner/repo/actions/runs/92/jobs", + "-f", "filter=all", "-F", "per_page=1", + ]] + + +def test_inspect_pr_recovers_startup_failure_before_other_actions(monkeypatch): + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setattr( + sched, + "recover_current_head_startup_failures", + lambda repo, pr, *, dry_run: [90], + ) + + decision = inspect(make_pr(headRefOid="a" * 40), dry_run=False) + + assert decision.action == "check_rerun" + assert "90" in decision.reason + + +def test_dispatch_strix_evidence_defers_to_bounded_admission_budget(monkeypatch, tmp_path): + """A fresh Strix dispatch (no existing job) respects the durable admission budget.""" + + def fake_run_with_env(args, *, stdin=None, env=None): + if "/actions/runs" in " ".join(args): + return '{"workflow_runs": []}' + return "" + + monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GH_TOKEN", "opencode-app-token") + monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") + pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [pr]) + + gate = sched.SchedulerAdmissionGate(tmp_path / "admission.json", sequence=1, dispatch_budget=0) + with sched.active_admission_gate(gate): + assert sched.dispatch_strix_evidence( + "ContextualWisdomLab/example", "Strix Security Scan", pr, dry_run=False + ) == "admission_deferred" + + +def test_dispatch_strix_evidence_rerun_defers_to_bounded_admission_budget(monkeypatch, tmp_path): + """Rerunning an existing Strix job also respects the durable admission budget.""" + pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + monkeypatch.setattr(sched, "matching_actions_job_id", lambda *_args: "202") + + gate = sched.SchedulerAdmissionGate(tmp_path / "admission.json", sequence=1, dispatch_budget=0) + with sched.active_admission_gate(gate): + assert sched.dispatch_strix_evidence( + "ContextualWisdomLab/example", "Strix Security Scan", pr, dry_run=False + ) == "admission_deferred" + + +def test_dispatch_strix_evidence_rerun_rechecks_live_head(monkeypatch): + """Rerunning an existing Strix job rechecks the exact live head first.""" + pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + monkeypatch.setattr(sched, "matching_actions_job_id", lambda *_args: "202") + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [make_pr(headRefOid="c" * 40)]) + + assert sched.dispatch_strix_evidence( + "owner/repo", "Strix Security Scan", pr, dry_run=False + ) == "stale_head" + + +def test_dispatch_strix_evidence_rechecks_live_head_before_new_dispatch(monkeypatch): + """A fresh Strix dispatch rechecks the exact live head immediately before dispatching.""" + + def fake_run_with_env(args, *, stdin=None, env=None): + if "/actions/runs" in " ".join(args): + return '{"workflow_runs": []}' + return "" + + monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GH_TOKEN", "opencode-app-token") + monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") + pr = make_pr(baseRefOid="b" * 40, headRefOid="a" * 40) + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [make_pr(headRefOid="c" * 40)]) + + assert sched.dispatch_strix_evidence( + "owner/repo", "Strix Security Scan", pr, dry_run=False + ) == "stale_head" + + +def test_missing_evidence_dispatch_uses_central_required_workflow_repository(monkeypatch): + calls = [] + head_sha = "a" * 40 + base_sha = "b" * 40 + + def fake_run_with_env(args, *, stdin=None, env=None): + calls.append((args, stdin, None if env is None else env.get("GH_TOKEN"))) + if "/actions/runs" in " ".join(args): + return '{"workflow_runs": []}' + return "" + + monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setenv("GH_TOKEN", "opencode-app-token") + monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-actions-token") + monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", "ContextualWisdomLab/.github") + monkeypatch.setenv("SCHEDULER_REQUIRED_WORKFLOW_REF", "main") + + pr = make_pr( + baseRefName="develop", + baseRefOid=base_sha, + headRefOid=head_sha, + statusCheckRollup={ + "contexts": { + "nodes": [ + opencode_check( + details_url="https://github.com/owner/repo/actions/runs/42/job/101" + ) + ] + } + }, + ) + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [pr]) + sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", pr, dry_run=False) + sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False) dispatch_calls = [call for call in calls if call[0][-2:] == ["--input", "-"]] strix_call = dispatch_calls[0][0] @@ -4683,6 +5383,19 @@ def test_stacked_pr_waits_when_opencode_dispatch_is_already_active(monkeypatch): assert stacked.reason == "stacked PR onto develop; same-head OpenCode workflow run is already active" +def test_stacked_pr_waits_on_bounded_admission_budget(monkeypatch): + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + + stacked = inspect(make_pr(baseRefName="develop")) + + assert stacked.action == "wait" + assert stacked.reason == "stacked PR onto develop; bounded admission budget is exhausted" + + def test_stacked_pr_waits_when_review_dispatch_budget_is_exhausted(): stacked = inspect(make_pr(baseRefName="develop"), review_dispatch_allowed=False) @@ -5396,6 +6109,101 @@ def test_dispatch_strix_waits_for_active_target_repository_run(monkeypatch, caps assert "target repository already has active run(s) ContextualWisdomLab/.github@9350" in capsys.readouterr().out +def test_central_run_filter_accepts_the_run_name_github_actually_sends(monkeypatch): + """A ``run-name:`` workflow reports the rendered title in ``name``. + + ``opencode-review-dispatch.yml``, ``strix.yml`` and ``noema-review.yml`` all + define ``run-name:``, so GitHub sets each run's ``name`` to the rendered + string, identical to ``display_title`` -- sampled 2026-09-07, 100 of 100 + opencode-review-dispatch runs carry that form and none carries the bare + workflow name. Matching ``name`` exactly against the aliases dropped every + one of them before the ``repository_dispatch`` branch that exists to read + them, so ``already_running`` never suppressed a same-head repeat and + ``stale`` never populated: .github#1529 took 27 dispatches on one unchanged + head, and older-head central runs were never cancelled. + + The neighbouring fixture below sets a bare ``name`` alongside a rendered + ``display_title``, which is why 100% coverage of that branch never showed + that production could not reach it. + """ + head_sha = "a" * 40 + stale_sha = "b" * 40 + current_title = f"Required OpenCode Review owner/repo#1@{head_sha}" + stale_title = f"Required OpenCode Review owner/repo#1@{stale_sha}" + central_runs = [ + { + "id": 9500, + "name": current_title, + "display_title": current_title, + "event": "repository_dispatch", + }, + { + "id": 9501, + "name": stale_title, + "display_title": stale_title, + "event": "repository_dispatch", + }, + ] + + def fake_active_runs(repo, statuses=("queued", "in_progress")): + del statuses + return central_runs if repo == "ContextualWisdomLab/.github" else [] + + monkeypatch.setattr(sched, "active_workflow_runs", fake_active_runs) + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + + assert sched.active_opencode_run_refs( + "owner/repo", + "OpenCode Review", + make_pr(headRefOid=head_sha), + ) == ( + [("ContextualWisdomLab/.github", "9500")], + [("ContextualWisdomLab/.github", "9501")], + ) + + +def test_central_run_filter_reads_the_rendered_strix_run_name_too(monkeypatch): + """Strix shares the matcher, and ``strix.yml`` also defines ``run-name:``. + + ``active_review_run_refs`` has exactly two call sites -- OpenCode's and + ``dispatch_strix_evidence``'s -- so the exact-``name`` match blinded both. + Pinning the Strix side here keeps a later narrowing of the fix to the + OpenCode aliases from silently reopening the Strix half. + """ + head_sha = "c" * 40 + current_title = f"Strix Security Scan owner/repo#1@{head_sha}" + + def fake_active_runs(repo, statuses=("queued", "in_progress")): + del statuses + if repo != "ContextualWisdomLab/.github": + return [] + return [ + { + "id": 9600, + "name": current_title, + "display_title": current_title, + "event": "repository_dispatch", + } + ] + + monkeypatch.setattr(sched, "active_workflow_runs", fake_active_runs) + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + + assert sched.active_review_run_refs( + "owner/repo", + "Strix Security Scan", + make_pr(headRefOid=head_sha), + run_title="Strix Security Scan", + workflow_aliases=frozenset({"Strix Security Scan"}), + ) == ([("ContextualWisdomLab/.github", "9600")], []) + + def test_central_run_filter_ignores_malformed_and_non_dispatch_titles(monkeypatch): head_sha = "a" * 40 central_runs = [ @@ -6268,6 +7076,14 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): assert coverage_active.reason == ( "current-head coverage evidence is complete, but a same-head OpenCode workflow run is already active" ) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + coverage_admission_deferred = inspect(coverage_request) + assert coverage_admission_deferred.action == "wait" + assert "bounded admission budget is exhausted" in coverage_admission_deferred.reason monkeypatch.setattr( sched, "dispatch_opencode_review", @@ -6766,6 +7582,30 @@ def test_draft_pr_review_request_marker_not_checked_when_flag_already_allows(mon assert decision.action == "security_dispatch" +def test_draft_pr_review_only_dispatch_waits_on_bounded_admission_budget(monkeypatch): + """A draft PR's review-only path defers to the same bounded admission budget.""" + monkeypatch.setattr( + sched, + "dispatch_strix_evidence", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + decision = inspect(make_pr(isDraft=True), allow_draft_review_dispatch=True) + assert decision.action == "wait" + assert "bounded admission budget is exhausted" in decision.reason + + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + strix_complete_draft = make_pr( + isDraft=True, statusCheckRollup={"contexts": {"nodes": [strix_check()]}} + ) + decision = inspect(strix_complete_draft, allow_draft_review_dispatch=True) + assert decision.action == "wait" + assert "bounded admission budget is exhausted" in decision.reason + + def test_draft_review_request_artifact_name_is_exact_and_stable(): assert sched.draft_review_request_artifact_name("owner/repo", 42, "a" * 40) == ( f"cwl-draft-review-request-owner-repo-42-{'a' * 40}" @@ -7309,6 +8149,9 @@ def test_inspect_pr_dispatches_strix_after_update_branch_observes_new_head(monke monkeypatch.setattr(sched, "update_branch", lambda repo, pr, dry_run: updated.append((repo, pr["headRefOid"], dry_run))) monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr(sched, "wait_for_updated_branch_head", lambda repo, pr: new_head_pr) monkeypatch.setattr( sched, @@ -7335,6 +8178,9 @@ def test_inspect_pr_notes_when_update_branch_head_is_not_observed(monkeypatch): monkeypatch.setattr(sched, "update_branch", lambda repo, pr, dry_run: updated.append(pr["number"])) monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr(sched, "wait_for_updated_branch_head", lambda repo, pr: None) decision = inspect(pr, dry_run=False) @@ -7361,6 +8207,9 @@ def test_inspect_pr_updates_outdated_branch_before_review_dispatch(monkeypatch): monkeypatch.setattr(sched, "update_branch", lambda repo, pr, dry_run: updated.append((repo, pr["headRefOid"], dry_run))) monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr(sched, "wait_for_updated_branch_head", lambda repo, pr: new_head_pr) monkeypatch.setattr( sched, @@ -7450,6 +8299,14 @@ def followup(updated_pr, **overrides): statusCheckRollup={"contexts": {"nodes": [strix_check(), opencode_check()]}}, ) ) + monkeypatch.setattr( + sched, + "dispatch_strix_evidence", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + assert "bounded admission budget is exhausted" in followup( + make_pr(headRefOid="new-head") + ) monkeypatch.setattr( sched, "dispatch_strix_evidence", @@ -7467,6 +8324,17 @@ def followup(updated_pr, **overrides): make_pr(headRefOid="new-head") ) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + assert "bounded admission budget is exhausted" in followup( + make_pr( + headRefOid="new-head", + statusCheckRollup={"contexts": {"nodes": [strix_check()]}}, + ) + ) monkeypatch.setattr( sched, "dispatch_opencode_review", @@ -7915,6 +8783,14 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): busy_strix = inspect(make_pr()) assert busy_strix.action == "wait" assert "target repository already has active Strix evidence" in busy_strix.reason + monkeypatch.setattr( + sched, + "dispatch_strix_evidence", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + admission_deferred_strix = inspect(make_pr()) + assert admission_deferred_strix.action == "wait" + assert "bounded admission budget is exhausted" in admission_deferred_strix.reason monkeypatch.setattr( sched, "dispatch_strix_evidence", @@ -7953,6 +8829,19 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): stale_already_active.reason == "OpenCode review exceeded the status-check retry threshold, but a same-head workflow run is already active" ) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + stale_admission_deferred = inspect(stale_opencode, stale_opencode_minutes=0) + assert stale_admission_deferred.action == "wait" + assert "bounded admission budget is exhausted" in stale_admission_deferred.reason + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "already_running", + ) stale_limited = inspect(stale_opencode, stale_opencode_minutes=0, review_dispatch_allowed=False) assert stale_limited.action == "wait" assert "review dispatch limit reached" in stale_limited.reason @@ -7979,6 +8868,21 @@ def test_inspect_pr_handles_approved_reviews_and_dispatch(monkeypatch): completed_strix_already_active.reason == "current head has completed Strix evidence; same-head OpenCode workflow run is already active" ) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "admission_deferred", + ) + completed_strix_admission_deferred = inspect( + make_pr(statusCheckRollup={"contexts": {"nodes": [strix_check()]}}), + ) + assert completed_strix_admission_deferred.action == "wait" + assert "bounded admission budget is exhausted" in completed_strix_admission_deferred.reason + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "already_running", + ) assert inspect(make_pr(), trigger_reviews=False).reason == "current head has no OpenCode approval" missing_approval_auto = inspect(make_pr(autoMergeRequest={"enabledAt": "now"}), trigger_reviews=False) assert missing_approval_auto.action == "disable_auto_merge" @@ -8154,6 +9058,9 @@ def test_main_limits_review_dispatches_and_branch_updates(monkeypatch, capsys): lambda repo, pr, dry_run: updated.append(pr["number"]), ) monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr(sched, "wait_for_updated_branch_head", lambda repo, pr: None) assert ( @@ -8190,6 +9097,45 @@ def test_main_limits_review_dispatches_and_branch_updates(monkeypatch, capsys): ) +def test_main_reconciles_the_durable_admission_gate_when_a_state_path_is_given( + monkeypatch, tmp_path +): + """`--admission-state-path` wires a real durable gate into the scan.""" + pr = make_pr(number=1, statusCheckRollup={"contexts": {"nodes": [strix_check()]}}) + dispatched = [] + monkeypatch.setattr(sched, "fetch_open_prs", lambda repo, max_prs: [pr]) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: dispatched.append(pr["number"]), + ) + monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, + "recover_current_head_startup_failures", + lambda repo, pr, *, dry_run: [], + ) + + state_path = tmp_path / "admission.json" + assert ( + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--admission-state-path", + str(state_path), + ] + ) + == 0 + ) + assert dispatched == [1] + assert state_path.exists() + + def test_main_prioritizes_stacked_prs_without_reordering_each_class(monkeypatch): prs = [ make_pr(number=1, baseRefName="main"), @@ -8308,6 +9254,38 @@ def test_main_rejects_invalid_review_dispatch_limit(): ) +def test_main_rejects_negative_admission_dispatch_budget(): + with pytest.raises(SystemExit, match="--admission-dispatch-budget must not be negative"): + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--admission-dispatch-budget", + "-1", + ] + ) + + +def test_main_rejects_non_positive_admission_sequence(): + with pytest.raises(SystemExit, match="--admission-sequence must be positive"): + sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "github-flow", + "--admission-sequence", + "0", + ] + ) + + def test_main_rejects_invalid_branch_update_limit(): with pytest.raises(SystemExit, match="--branch-update-limit must be -1 or greater"): sched.main( @@ -8458,6 +9436,38 @@ def fake_inspect(repo, pr, **kwargs): assert payload["decisions"][1]["contract_decision"] == "WAIT" +def test_main_stops_scan_and_propagates_on_mid_scan_rate_limit(monkeypatch, capsys): + """A rate limit raised from inside inspect_pr() (not the pre-loop fetch) + must stop the sweep and exit non-zero, exactly like the pre-loop path. + + Folding it into an ordinary action_error decision and continuing the + loop -- the pre-existing behavior for every other RuntimeError, see + test_main_keeps_scanning_after_action_error -- would keep spending the + same exhausted shared-installation bucket on every remaining PR, and a + zero exit code would never reach pr-review-merge-scheduler.yml's + "API rate limit exceeded" skip-and-defer branch, which greps sweep_rc + != 0. + """ + prs = [make_pr(number=1), make_pr(number=2)] + seen = [] + + def fake_inspect(repo, pr, **kwargs): + seen.append(pr["number"]) + raise RuntimeError("gh: API rate limit exceeded for installation ID 141441800. (HTTP 403)") + + monkeypatch.setattr(sched, "fetch_open_prs", lambda repo, max_prs: prs) + monkeypatch.setattr(sched, "inspect_pr", fake_inspect) + + with pytest.raises(RuntimeError, match="API rate limit exceeded"): + sched.main(["--repo", "owner/repo", "--base-branch", "main", "--project-flow", "github"]) + + assert seen == [1] + output = capsys.readouterr().out + assert "PR #1: action_error: gh: API rate limit exceeded for installation ID 141441800. (HTTP 403)" in output + payload = json.loads(output.strip().splitlines()[-1]) + assert payload["counts"] == {"action_error": 1} + + def test_scrub_sensitive_data_and_run_error(): assert sched.scrub_sensitive_data("Authorization: Bearer mytoken123") == "Authorization: Bearer ***" assert sched.scrub_sensitive_data("token mytoken123") == "token ***" @@ -8805,6 +9815,9 @@ def test_inspect_pr_direct_merge_blocked_when_approval_revoked_before_merge(monk fetch_calls = [] merge_calls = [] monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr( sched, "fetch_pr", @@ -8831,6 +9844,9 @@ def test_inspect_pr_direct_or_auto_merge_blocked_when_approval_revoked_before_me fetch_calls = [] merge_calls = [] monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr( sched, "fetch_pr", @@ -8856,6 +9872,9 @@ def test_inspect_pr_auto_merge_blocked_when_approval_revoked_before_enable(monke fetch_calls = [] auto_merge_calls = [] monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr( sched, "fetch_pr", @@ -8893,6 +9912,9 @@ def test_inspect_pr_disables_queued_auto_merge_when_approval_revoked_before_merg merge_calls = [] disabled = [] monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr( sched, "fetch_pr", @@ -8926,6 +9948,9 @@ def test_inspect_pr_blocked_direct_or_auto_merge_blocked_when_approval_revoked_b fetch_calls = [] merge_calls = [] monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr( sched, "fetch_pr", @@ -8952,6 +9977,9 @@ def test_inspect_pr_blocked_auto_merge_blocked_when_approval_revoked_before_enab fetch_calls = [] auto_merge_calls = [] monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr( sched, "fetch_pr", @@ -8979,6 +10007,9 @@ def test_inspect_pr_direct_merge_proceeds_when_revalidation_confirms_approval(mo fetch_calls = [] merge_calls = [] monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr( sched, "fetch_pr", @@ -9007,6 +10038,9 @@ def raise_refetch(repo, number): raise RuntimeError("gh api graphql: 502 Bad Gateway") monkeypatch.setattr(sched, "cancel_stale_pr_runs", lambda repo, pr, dry_run: []) + monkeypatch.setattr( + sched, "recover_current_head_startup_failures", lambda repo, pr, *, dry_run: [] + ) monkeypatch.setattr(sched, "fetch_pr", raise_refetch) monkeypatch.setattr( sched, "merge_pr", lambda repo, pr, dry_run: merge_calls.append((repo, pr["number"], dry_run)) @@ -9547,3 +10581,212 @@ def fake_api(path): assert sched._review_run_still_superseded( "owner/repo", "Strix Security Scan", 7, "ContextualWisdomLab/.github", "97" ) is True + + +def test_admission_gate_rejects_invalid_sequence_and_budget(tmp_path): + """The gate validates its own constructor inputs independent of the CLI.""" + state_path = tmp_path / "admission.json" + with pytest.raises(ValueError, match="admission sequence must be positive"): + sched.SchedulerAdmissionGate(state_path, sequence=0, dispatch_budget=1) + with pytest.raises(ValueError, match="admission dispatch budget must not be negative"): + sched.SchedulerAdmissionGate(state_path, sequence=1, dispatch_budget=-1) + + +def test_bounded_admission_persists_leases_and_completes_only_current_head( + monkeypatch, tmp_path +): + """One durable budget slot prevents a second worker until exact-head completion.""" + state_path = tmp_path / "admission.json" + gate = sched.SchedulerAdmissionGate(state_path, sequence=77, dispatch_budget=1) + pr = make_pr(number=7, headRefOid="a" * 40) + + assert gate.admit("opencode", "ContextualWisdomLab/example", pr) is True + assert gate.admit("strix", "ContextualWisdomLab/example", pr) is False + from scripts.ci.review_admission_controller import load_state_file + + persisted = load_state_file(state_path) + assert [record.status for record in persisted.records.values()].count("dispatched") == 1 + assert [record.status for record in persisted.records.values()].count("queued") == 1 + + monkeypatch.setattr(sched, "has_current_head_approval", lambda _pr: True) + monkeypatch.setattr(sched, "has_current_head_changes_requested", lambda _pr: False) + gate.reconcile("ContextualWisdomLab/example", [pr]) + + assert gate.admit("strix", "ContextualWisdomLab/example", pr) is True + persisted = load_state_file(state_path) + assert [record.status for record in persisted.records.values()].count("complete") == 1 + assert [record.status for record in persisted.records.values()].count("dispatched") == 1 + + +def test_actual_opencode_dispatch_path_obeys_one_shared_admission_budget( + monkeypatch, tmp_path +): + """Two eligible PRs create only one worker dispatch under a one-slot budget.""" + gate = sched.SchedulerAdmissionGate( + tmp_path / "admission.json", sequence=88, dispatch_budget=1 + ) + dispatched = [] + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda _action: None) + monkeypatch.setattr(sched, "active_opencode_run_refs", lambda *_args: ([], [])) + monkeypatch.setattr( + sched, "_cancel_revalidated_review_run_refs", lambda *_args: ([], []) + ) + monkeypatch.setattr(sched, "complete_paginated_pr_contexts", lambda *_args: None) + monkeypatch.setattr(sched, "matching_actions_run_id", lambda *_args: None) + monkeypatch.setattr(sched, "discover_opencode_required_run_id", lambda *_args: None) + monkeypatch.setattr(sched, "repository_dispatch_target", lambda _repo: "ContextualWisdomLab/.github") + monkeypatch.setattr( + sched, + "run_github_dispatch", + lambda args, *, stdin=None: dispatched.append((args, stdin)), + ) + + first = make_pr( + number=7, + baseRefOid="b" * 40, + headRefOid="a" * 40, + headRefName="feature-a", + ) + second = make_pr( + number=8, + baseRefOid="b" * 40, + headRefOid="c" * 40, + headRefName="feature-b", + ) + monkeypatch.setattr( + sched, + "fetch_pr", + lambda _repo, number: [first if number == 7 else second], + ) + with sched.active_admission_gate(gate): + assert sched.dispatch_opencode_review( + "ContextualWisdomLab/example", "Required OpenCode Review", first, dry_run=False + ) == "dispatched" + assert sched.dispatch_opencode_review( + "ContextualWisdomLab/example", "Required OpenCode Review", second, dry_run=False + ) == "admission_deferred" + + assert len(dispatched) == 1 + + +def test_opencode_dispatch_rechecks_live_head_immediately_before_side_effect( + monkeypatch, tmp_path +): + gate = sched.SchedulerAdmissionGate( + tmp_path / "admission.json", sequence=89, dispatch_budget=1 + ) + pr = make_pr(number=7, baseRefOid="b" * 40, headRefOid="a" * 40, headRefName="feature") + dispatched = [] + monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda _action: None) + monkeypatch.setattr(sched, "active_opencode_run_refs", lambda *_args: ([], [])) + monkeypatch.setattr(sched, "_cancel_revalidated_review_run_refs", lambda *_args: ([], [])) + monkeypatch.setattr(sched, "complete_paginated_pr_contexts", lambda *_args: None) + monkeypatch.setattr(sched, "matching_actions_run_id", lambda *_args: None) + monkeypatch.setattr(sched, "discover_opencode_required_run_id", lambda *_args: None) + monkeypatch.setattr(sched, "repository_dispatch_target", lambda _repo: "ContextualWisdomLab/.github") + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [make_pr(number=7, headRefOid="c" * 40)]) + monkeypatch.setattr(sched, "run_github_dispatch", lambda args, *, stdin=None: dispatched.append((args, stdin))) + + with sched.active_admission_gate(gate): + assert sched.dispatch_opencode_review( + "ContextualWisdomLab/example", "Required OpenCode Review", pr, dry_run=False + ) == "stale_head" + assert dispatched == [] + + +def test_reconcile_marks_lease_stale_when_live_head_has_moved(tmp_path): + """A lease recorded against a superseded head is retired without inspecting evidence.""" + gate = sched.SchedulerAdmissionGate( + tmp_path / "admission.json", sequence=91, dispatch_budget=1 + ) + pr = make_pr(number=7, headRefOid="a" * 40) + assert gate.admit("strix", "ContextualWisdomLab/example", pr) + moved_pr = make_pr(number=7, headRefOid="b" * 40) + gate.reconcile("ContextualWisdomLab/example", [moved_pr]) + + from scripts.ci.review_admission_controller import load_state_file + + record = next(iter(load_state_file(gate.state_path).records.values())) + assert record.status == "stale" + + +def test_reconcile_keeps_lease_dispatched_while_strix_is_still_running(tmp_path): + """A lease for an in-flight, same-head scan is neither completed nor retired.""" + gate = sched.SchedulerAdmissionGate( + tmp_path / "admission.json", sequence=92, dispatch_budget=1 + ) + pr = make_pr( + number=7, + headRefOid="a" * 40, + statusCheckRollup={ + "contexts": {"nodes": [strix_check(status="IN_PROGRESS", conclusion="")]} + }, + ) + assert gate.admit("strix", "ContextualWisdomLab/example", pr) + gate.reconcile("ContextualWisdomLab/example", [pr]) + + from scripts.ci.review_admission_controller import load_state_file + + record = next(iter(load_state_file(gate.state_path).records.values())) + assert record.status == "dispatched" + + +def test_reconcile_releases_strix_lease_when_no_run_was_created(tmp_path): + gate = sched.SchedulerAdmissionGate( + tmp_path / "admission.json", sequence=90, dispatch_budget=1 + ) + pr = make_pr(number=7, headRefOid="a" * 40) + assert gate.admit("strix", "ContextualWisdomLab/example", pr) + gate.reconcile("ContextualWisdomLab/example", [pr]) + + from scripts.ci.review_admission_controller import load_state_file + + record = next(iter(load_state_file(gate.state_path).records.values())) + assert record.status == "stale" + + +def test_inspect_pr_holds_pre_review_update_while_current_head_checks_run(): + """A behind, unreviewed head keeps its queued checks instead of being updated (#1935). + + Under a saturated queue the PR's own delayed scheduler run used to merge + ``main`` into the head before review dispatch, cancelling every queued + check on the old head and requeueing the PR behind them. The hold has no + age cap on purpose: a check that never finishes keeps the head in place + rather than restarting that loop, and the update resumes as soon as every + newest check run has a terminal status. + """ + + def behind_with(nodes): + return make_pr( + mergeStateStatus="BEHIND", + statusCheckRollup={"contexts": {"nodes": nodes}}, + ) + + held = inspect( + behind_with( + [ + {"__typename": "CheckRun", "name": "trivy-fs", "status": "QUEUED", "conclusion": None}, + {"__typename": "CheckRun", "name": "scan-pr-queue", "status": "IN_PROGRESS", "conclusion": None}, + {"__typename": "CheckRun", "name": "osv-scan", "status": "COMPLETED", "conclusion": "SUCCESS"}, + ] + ) + ) + assert held.action == "wait" + assert "branch is outdated before review dispatch" in held.reason + assert "checks are still queued or running" in held.reason + + resumed = inspect( + behind_with( + [ + {"__typename": "CheckRun", "name": "trivy-fs", "status": "COMPLETED", "conclusion": "SUCCESS"}, + {"__typename": "CheckRun", "name": "scan-pr-queue", "status": "COMPLETED", "conclusion": "SKIPPED"}, + ] + ) + ) + assert resumed.action == "update_branch" + assert resumed.reason.startswith( + "current head has no OpenCode approval; branch is outdated before review dispatch" + ) + assert "checks are still queued or running" not in resumed.reason + + assert sched.has_in_flight_check_runs(behind_with([])) is False diff --git a/tests/test_queue_cancellation_open_pr_revalidation.py b/tests/test_queue_cancellation_open_pr_revalidation.py deleted file mode 100644 index 72ecc9b9e9..0000000000 --- a/tests/test_queue_cancellation_open_pr_revalidation.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Regressions for aged PR-run cancellation after late PR association.""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parents[1] -SCRIPT = REPO_ROOT / "scripts" / "ci" / "revalidate_queue_cancellation.sh" - - -def _run_late_association_case( - tmp_path: Path, *, payload_sha: str, live_ref_sha: str, fail_ref: bool = False -) -> tuple[subprocess.CompletedProcess[str], bool]: - if shutil.which("jq") is None: - pytest.skip("jq is required for the queue-cancellation regression") - - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - cancelled = tmp_path / "cancelled" - current = "b" * 40 - run_payload = json.dumps( - { - "event": "pull_request", - "status": "queued", - "head_sha": current, - "head_branch": "feature/late-pr", - "head_repository": {"full_name": "ContextualWisdomLab/example"}, - "pull_requests": [], - }, - separators=(",", ":"), - ) - # Deliberately include a payload SHA that may lag the authoritative branch - # ref. The helper must use this response only to discover repo/ref identity. - open_prs = json.dumps( - [ - { - "state": "open", - "head": { - "repo": {"full_name": "ContextualWisdomLab/example"}, - "ref": "feature/late-pr", - "sha": payload_sha, - }, - } - ], - separators=(",", ":"), - ) - fake_gh = bin_dir / "gh" - fake_gh.write_text( - f"""#!/usr/bin/env bash -set -euo pipefail -args="$*" -if [[ "$args" == *"/actions/runs/77/cancel"* ]]; then - : > {cancelled!s} - exit 0 -fi -if [[ "$args" == *"/actions/runs/77"* ]]; then - printf '%s\\n' '{run_payload}' - exit 0 -fi -if [[ "$args" == *"/pulls?state=open&per_page=100"* ]]; then - printf '%s\\n' '{open_prs}' - exit 0 -fi -if [[ "$args" == *"/git/ref/heads/feature/late-pr"* ]]; then - {'exit 74' if fail_ref else f"printf '%s\\n' '{live_ref_sha}'"} - exit 0 -fi -exit 79 -""", - encoding="utf-8", - ) - fake_gh.chmod(0o755) - env = os.environ.copy() - env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" - result = subprocess.run( - [ - "bash", - str(SCRIPT), - "ContextualWisdomLab/example", - "77", - "main", - "d" * 40, - "{}", - "aged-orphan", - ], - capture_output=True, - text=True, - env=env, - check=False, - ) - return result, cancelled.exists() - - -def test_aged_unassociated_pr_run_resolves_authoritative_live_ref(tmp_path: Path) -> None: - """A stale PR payload cannot authorize cancellation of the live current head.""" - current = "b" * 40 - stale_payload = "a" * 40 - result, cancelled = _run_late_association_case( - tmp_path, - payload_sha=stale_payload, - live_ref_sha=current, - ) - - assert result.returncode == 0, result.stderr - assert "authoritative current head" in result.stdout - assert not cancelled - - -def test_aged_unassociated_pr_run_fails_closed_when_live_ref_is_unreadable( - tmp_path: Path, -) -> None: - """A matching late PR with unreadable ref must preserve the queued run.""" - result, cancelled = _run_late_association_case( - tmp_path, - payload_sha="a" * 40, - live_ref_sha="b" * 40, - fail_ref=True, - ) - - assert result.returncode == 0, result.stderr - assert "could not be re-fetched" in result.stdout - assert not cancelled diff --git a/tests/test_queue_cancellation_revalidation.py b/tests/test_queue_cancellation_revalidation.py deleted file mode 100644 index e8e6b57492..0000000000 --- a/tests/test_queue_cancellation_revalidation.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Executable regressions for destructive queue-cancellation revalidation.""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - - -REPO_ROOT = Path(__file__).resolve().parents[1] -SCRIPT = REPO_ROOT / "scripts" / "ci" / "revalidate_queue_cancellation.sh" - - -def _run_case( - tmp_path: Path, - *, - snapshot_sha: str, - pr_sha: str, - ref_sha: str, - run_sha: str, - fail_lookup: str | None = None, -) -> tuple[subprocess.CompletedProcess[str], bool]: - """Run the production shell helper against a deterministic fake GitHub CLI.""" - if shutil.which("jq") is None: - pytest.skip("jq is required for the queue-cancellation regression") - - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - cancelled = tmp_path / "cancelled" - pr_payload = json.dumps( - { - "state": "open", - "head": { - "repo": {"full_name": "ContextualWisdomLab/example"}, - "ref": "feature/race", - "sha": pr_sha, - }, - }, - separators=(",", ":"), - ) - run_payload = json.dumps( - { - "event": "pull_request", - "status": "queued", - "head_sha": run_sha, - "pull_requests": [{"number": 12}], - }, - separators=(",", ":"), - ) - fake_gh = bin_dir / "gh" - fake_gh.write_text( - f"""#!/usr/bin/env bash -set -euo pipefail -args="$*" -if [[ "$args" == *"/actions/runs/77/cancel"* ]]; then - : > {cancelled!s} - exit 0 -fi -if [[ "$args" == *"/actions/runs/77"* ]]; then - printf '%s\\n' '{run_payload}' - exit 0 -fi -if [[ "$args" == *"/pulls/12"* ]]; then - {'exit 73' if fail_lookup == 'pr' else f"printf '%s\\n' '{pr_payload}'"} - exit 0 -fi -if [[ "$args" == *"/git/ref/heads/feature/race"* ]]; then - {'exit 74' if fail_lookup == 'ref' else f"printf '%s\\n' '{ref_sha}'"} - exit 0 -fi -exit 79 -""", - encoding="utf-8", - ) - fake_gh.chmod(0o755) - env = os.environ.copy() - env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" - snapshot = json.dumps( - {"ContextualWisdomLab/example:feature/race": snapshot_sha}, - separators=(",", ":"), - ) - result = subprocess.run( - [ - "bash", - str(SCRIPT), - "ContextualWisdomLab/example", - "77", - "main", - "d" * 40, - snapshot, - "superseded", - ], - capture_output=True, - text=True, - env=env, - check=False, - ) - return result, cancelled.exists() - - -def _run_aged_orphan_case( - tmp_path: Path, *, event: str, status: str = "queued" -) -> tuple[subprocess.CompletedProcess[str], bool]: - """Run an aged orphan candidate that has no current PR/default-branch authority.""" - if shutil.which("jq") is None: - pytest.skip("jq is required for the queue-cancellation regression") - - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - cancelled = tmp_path / "cancelled" - run_payload = json.dumps( - { - "event": event, - "status": status, - "head_sha": "a" * 40, - "pull_requests": [], - }, - separators=(",", ":"), - ) - fake_gh = bin_dir / "gh" - fake_gh.write_text( - f"""#!/usr/bin/env bash -set -euo pipefail -args="$*" -if [[ "$args" == *"/actions/runs/77/cancel"* ]]; then - : > {cancelled!s} - exit 0 -fi -if [[ "$args" == *"/actions/runs/77"* ]]; then - printf '%s\\n' '{run_payload}' - exit 0 -fi -exit 79 -""", - encoding="utf-8", - ) - fake_gh.chmod(0o755) - env = os.environ.copy() - env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" - result = subprocess.run( - [ - "bash", - str(SCRIPT), - "ContextualWisdomLab/example", - "77", - "main", - "d" * 40, - "{}", - "aged-orphan", - ], - capture_output=True, - text=True, - env=env, - check=False, - ) - return result, cancelled.exists() - - -def _run_unassociated_pr_aged_orphan_case( - tmp_path: Path, - *, - listed_sha: str, - ref_sha: str, - run_sha: str, - fail_ref_lookup: bool = False, -) -> tuple[subprocess.CompletedProcess[str], bool]: - """Run an unassociated aged PR run against stale listing and live-ref evidence.""" - if shutil.which("jq") is None: - pytest.skip("jq is required for the queue-cancellation regression") - - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - cancelled = tmp_path / "cancelled" - run_payload = json.dumps( - { - "event": "pull_request", - "status": "queued", - "head_sha": run_sha, - "head_branch": "feature/race", - "head_repository": {"full_name": "ContextualWisdomLab/example"}, - "pull_requests": [], - }, - separators=(",", ":"), - ) - open_pr_payload = json.dumps( - [ - { - "head": { - "repo": {"full_name": "ContextualWisdomLab/example"}, - "ref": "feature/race", - "sha": listed_sha, - } - } - ], - separators=(",", ":"), - ) - fake_gh = bin_dir / "gh" - fake_gh.write_text( - f"""#!/usr/bin/env bash -set -euo pipefail -args="$*" -if [[ "$args" == *"/actions/runs/77/cancel"* ]]; then - : > {cancelled!s} - exit 0 -fi -if [[ "$args" == *"/actions/runs/77"* ]]; then - printf '%s\\n' '{run_payload}' - exit 0 -fi -if [[ "$args" == *"/pulls?state=open&per_page=100"* ]]; then - printf '%s\\n' '{open_pr_payload}' - exit 0 -fi -if [[ "$args" == *"/git/ref/heads/feature/race"* ]]; then - {'exit 74' if fail_ref_lookup else f"printf '%s\\n' '{ref_sha}'"} - exit 0 -fi -exit 79 -""", - encoding="utf-8", - ) - fake_gh.chmod(0o755) - env = os.environ.copy() - env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" - result = subprocess.run( - [ - "bash", - str(SCRIPT), - "ContextualWisdomLab/example", - "77", - "main", - "d" * 40, - "{}", - "aged-orphan", - ], - capture_output=True, - text=True, - env=env, - check=False, - ) - return result, cancelled.exists() - - -def test_post_classification_head_movement_fails_closed(tmp_path: Path) -> None: - """A new exact head arriving after classification must never be cancelled.""" - old = "a" * 40 - new = "b" * 40 - result, cancelled = _run_case( - tmp_path, - snapshot_sha=old, - pr_sha=new, - ref_sha=new, - run_sha=new, - ) - assert result.returncode == 0, result.stderr - assert "moved after queue classification" in result.stdout - assert not cancelled - - -@pytest.mark.parametrize("failed_lookup", ["pr", "ref"]) -def test_final_lookup_failure_fails_closed( - tmp_path: Path, failed_lookup: str -) -> None: - """Unavailable final authoritative PR/ref state must preserve the candidate.""" - current = "b" * 40 - result, cancelled = _run_case( - tmp_path, - snapshot_sha=current, - pr_sha=current, - ref_sha=current, - run_sha="a" * 40, - fail_lookup=failed_lookup, - ) - assert result.returncode == 0, result.stderr - assert "could not be re-fetched" in result.stdout - assert not cancelled - - -def test_current_head_is_preserved(tmp_path: Path) -> None: - """Final live-ref validation must preserve sole current-head evidence.""" - current = "b" * 40 - result, cancelled = _run_case( - tmp_path, - snapshot_sha=current, - pr_sha=current, - ref_sha=current, - run_sha=current, - ) - assert result.returncode == 0, result.stderr - assert "authoritative current-head evidence" in result.stdout - assert not cancelled - - -def test_proven_predecessor_is_cancelled(tmp_path: Path) -> None: - """An unchanged final live ref may cancel a proven predecessor run.""" - current = "b" * 40 - result, cancelled = _run_case( - tmp_path, - snapshot_sha=current, - pr_sha=current, - ref_sha=current, - run_sha="a" * 40, - ) - assert result.returncode == 0, result.stderr - assert cancelled - - -def test_unassociated_aged_pr_uses_live_ref_not_stale_listing_sha( - tmp_path: Path, -) -> None: - """A stale PR payload cannot authorize cancelling the live branch head.""" - listed = "a" * 40 - current = "b" * 40 - result, cancelled = _run_unassociated_pr_aged_orphan_case( - tmp_path, - listed_sha=listed, - ref_sha=current, - run_sha=current, - ) - assert result.returncode == 0, result.stderr - assert "associated with an open PR at its authoritative current head" in result.stdout - assert not cancelled - - -def test_unassociated_aged_pr_live_ref_lookup_failure_fails_closed( - tmp_path: Path, -) -> None: - """Missing final ref evidence must preserve an unassociated PR candidate.""" - result, cancelled = _run_unassociated_pr_aged_orphan_case( - tmp_path, - listed_sha="a" * 40, - ref_sha="b" * 40, - run_sha="b" * 40, - fail_ref_lookup=True, - ) - assert result.returncode == 0, result.stderr - assert "live ref" in result.stdout - assert "could not be re-fetched" in result.stdout - assert not cancelled - - -@pytest.mark.parametrize( - "event", - ["workflow_dispatch", "workflow_run", "repository_dispatch", "issues"], -) -def test_aged_orphan_events_remain_cancellable(tmp_path: Path, event: str) -> None: - """Final revalidation must not disable legacy aged-orphan queue cleanup.""" - result, cancelled = _run_aged_orphan_case(tmp_path, event=event) - assert result.returncode == 0, result.stderr - assert cancelled - - -def test_aged_orphan_that_started_running_is_preserved(tmp_path: Path) -> None: - """Aged-orphan mode applies only while the candidate is still queued.""" - result, cancelled = _run_aged_orphan_case( - tmp_path, event="workflow_dispatch", status="in_progress" - ) - assert result.returncode == 0, result.stderr - assert "no longer queued" in result.stdout - assert not cancelled diff --git a/tests/test_queue_cancellation_scheduler_contract.py b/tests/test_queue_cancellation_scheduler_contract.py deleted file mode 100644 index 70f9d75e8c..0000000000 --- a/tests/test_queue_cancellation_scheduler_contract.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Structural contracts for final-state queue cancellation revalidation.""" - -from __future__ import annotations - -import os -from pathlib import Path - - -ROOT = Path(__file__).resolve().parents[1] -WORKFLOW = ROOT / ".github" / "workflows" / "pr-review-merge-scheduler.yml" -HELPER = ROOT / "scripts" / "ci" / "revalidate_queue_cancellation.sh" -TEMP_WRITER = ROOT / ".github" / "workflows" / "_temp_pr1348_final_revalidation_repair.yml" - - -def test_scheduler_revalidates_each_destructive_candidate() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - - assert workflow.count("scripts/ci/revalidate_queue_cancellation.sh") == 2 - assert '"superseded"' in workflow - assert '"aged-orphan"' in workflow - assert 'gh api -X POST "/repos/${repo_full_name}/actions/runs/${run_id}/cancel"' not in workflow - - -def test_initial_snapshot_is_bounded_without_serial_live_ref_fanout() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - queue_block = workflow.split("# Queue hygiene, part 1:", 1)[1].split( - "# Queue hygiene, part 2:", 1 - )[0] - - assert "/pulls?state=open&per_page=100" in queue_block - assert "all(.[];" in queue_block - assert 'test("^[0-9a-fA-F]{40}$")' in queue_block - assert "/git/ref/heads/" not in queue_block - assert "ORG_QUEUE_HYGIENE_MAX_REF_LOOKUPS" not in workflow - - -def test_revalidation_helper_is_executable_and_temp_writer_is_retired() -> None: - assert HELPER.is_file() - assert os.access(HELPER, os.X_OK) - assert not TEMP_WRITER.exists() - - -def test_reconciled_scheduler_preserves_current_main_control_plane_fixes() -> None: - workflow = WORKFLOW.read_text(encoding="utf-8") - - assert '- cron: "0 * * * *"' in workflow - assert '*/15 * * * *' not in workflow - assert workflow.count("runs-on: ubuntu-24.04") >= 2 - scan_job = workflow.split(" scan-pr-queue:", 1)[1].split(" org-queue-sweep:", 1)[0] - assert "github.event_name == 'pull_request_review'" in scan_job.split( - "TRIGGER_REVIEWS:", 1 - )[1].splitlines()[0] diff --git a/tests/test_r_package_check_reusable_workflow_contract.py b/tests/test_r_package_check_reusable_workflow_contract.py new file mode 100644 index 0000000000..2d3ad49711 --- /dev/null +++ b/tests/test_r_package_check_reusable_workflow_contract.py @@ -0,0 +1,131 @@ +"""Contract for the reusable R-CMD-check workflow. + +Replaces kaefa's and nonnest2's near-identical, hand-copied +``R-CMD-check.yaml`` files with one reusable ``workflow_call`` workflow, +``.github/workflows/r-package-check.yml``, plus a thin caller left in each +product repository. See +``docs/doctoring/r-cmd-check-reusable-workflow-consolidation.md`` and +``docs/adr/0023-r-cmd-check-reusable-workflow-consolidation.md`` for why. +""" + +from __future__ import annotations + +from pathlib import Path + +_WORKFLOW = Path(".github/workflows/r-package-check.yml") + +_R_LIB_PIN = "6f6e5bc62fba3a704f74e7ad7ef7676c5c6a2590" +_CHECKOUT_PIN = "3d3c42e5aac5ba805825da76410c181273ba90b1" + + +def _workflow_text() -> str: + """Read the reusable R-CMD-check workflow as UTF-8 text.""" + return _WORKFLOW.read_text(encoding="utf-8") + + +def test_declares_workflow_call_with_six_inputs_and_recorded_defaults() -> None: + """Every genuinely varying caller field is data, never executable shell source.""" + workflow = _workflow_text() + assert "on:\n workflow_call:\n inputs:" in workflow + for name in ( + "r_matrix:", + "needs_tinytex:", + "extra_packages:", + "check_args:", + "install_package_before_pre_check:", + "pre_check_test_file:", + ): + assert name in workflow + + assert 'default: \'[{"os": "ubuntu-latest", "r": "release"}]\'' in workflow + assert workflow.count("default: false") >= 2 + assert 'default: "any::rcmdcheck"' in workflow + assert "default: 'c(\"--no-manual\", \"--as-cran\")'" in workflow + assert 'default: ""' in workflow + + +def test_step_order_matches_the_r_lib_template_sequence() -> None: + """checkout -> pandoc -> [tinytex] -> setup-r -> deps -> bounded pre-check -> check.""" + workflow = _workflow_text() + order = [ + "actions/checkout@", + "r-lib/actions/setup-pandoc@", + "r-lib/actions/setup-tinytex@", + "r-lib/actions/setup-r@", + "r-lib/actions/setup-r-dependencies@", + "Install package for bounded pre-check", + "Run bounded testthat pre-check", + "r-lib/actions/check-r-package@", + ] + positions = [workflow.index(marker) for marker in order] + assert positions == sorted(positions), "steps are out of order" + + +def test_optional_steps_are_gated_on_bounded_inputs() -> None: + """Optional setup and pre-check steps run only for explicit bounded capabilities.""" + workflow = _workflow_text() + assert ( + "- if: inputs.needs_tinytex\n uses: r-lib/actions/setup-tinytex@" + in workflow + ) + assert ( + "- if: inputs.pre_check_test_file != '' && inputs.install_package_before_pre_check\n" + " name: Install package for bounded pre-check" + in workflow + ) + assert ( + "- if: inputs.pre_check_test_file != ''\n" + " name: Run bounded testthat pre-check" + in workflow + ) + assert "PRE_CHECK_TEST_FILE: ${{ inputs.pre_check_test_file }}" in workflow + assert 'testthat::test_file(Sys.getenv("PRE_CHECK_TEST_FILE"))' in workflow + + +def test_action_pins_are_uniform_and_current() -> None: + """Every r-lib step and checkout share one current pin, not per-caller drift.""" + workflow = _workflow_text() + assert workflow.count(_R_LIB_PIN) == 5 # pandoc, tinytex, setup-r, deps, check + assert f"actions/checkout@{_CHECKOUT_PIN}" in workflow + assert f"r-lib/actions/setup-pandoc@{_R_LIB_PIN}" in workflow + assert f"r-lib/actions/setup-tinytex@{_R_LIB_PIN}" in workflow + assert f"r-lib/actions/setup-r@{_R_LIB_PIN}" in workflow + assert f"r-lib/actions/setup-r-dependencies@{_R_LIB_PIN}" in workflow + assert f"r-lib/actions/check-r-package@{_R_LIB_PIN}" in workflow + + +def test_uniform_fields_are_hardcoded_not_parameterized() -> None: + """Fields byte-identical across both originals stay static, not inputs.""" + workflow = _workflow_text() + assert "permissions:\n contents: read" in workflow + assert "GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}" in workflow + assert "R_KEEP_PKG_SOURCE: yes" in workflow + assert "build_args: 'c(\"--no-manual\")'" in workflow + assert "error-on: '\"error\"'" in workflow + assert "upload-snapshots: true" in workflow + assert "args: ${{ inputs.check_args }}" in workflow + assert "extra-packages: ${{ inputs.extra_packages }}" in workflow + + +def test_matrix_is_driven_by_the_r_matrix_input() -> None: + """The strategy matrix must come from fromJSON(inputs.r_matrix), not a fixed list.""" + workflow = _workflow_text() + assert "config: ${{ fromJSON(inputs.r_matrix) }}" in workflow + assert "runs-on: ${{ matrix.config.os }}" in workflow + assert "r-version: ${{ matrix.config.r }}" in workflow + assert "http-user-agent: ${{ matrix.config['http-user-agent'] }}" in workflow + + +def test_pre_check_hook_is_bounded_data_not_caller_shell_source() -> None: + """A reusable caller must not inject arbitrary Bash source into the trusted job.""" + workflow = _workflow_text() + assert "pre_check_script:" not in workflow + assert "run: ${{ inputs.pre_check_script }}" not in workflow + assert "pre_check_test_file:" in workflow + assert "install_package_before_pre_check:" in workflow + assert "PRE_CHECK_TEST_FILE: ${{ inputs.pre_check_test_file }}" in workflow + assert 'case "$PRE_CHECK_TEST_FILE" in' in workflow + assert "tests/testthat/*.R" in workflow + assert '"$PRE_CHECK_TEST_FILE" == *".."*' in workflow + assert '"$PRE_CHECK_TEST_FILE" == /*' in workflow + assert 'testthat::test_file(Sys.getenv("PRE_CHECK_TEST_FILE"))' in workflow diff --git a/tests/test_redact_sensitive_log_json_array.py b/tests/test_redact_sensitive_log_json_array.py new file mode 100644 index 0000000000..0f445ea642 --- /dev/null +++ b/tests/test_redact_sensitive_log_json_array.py @@ -0,0 +1,26 @@ +import pytest +from scripts.ci.redact_sensitive_log import redact_text + +def test_redact_json_array_preserves_array(): + """Verify that a valid JSON array is parsed and its inner objects redacted.""" + source = ' [{"token": "secret"}]' + redacted = redact_text(source) + assert '{"token":"[REDACTED]"}' in redacted + +def test_redact_json_array_invalid_json(): + """Verify that a line starting with '[' but not valid JSON falls back safely.""" + source = ' [not a json array]' + redacted = redact_text(source) + assert redacted == ' [not a json array]' + +def test_redact_scalar_json(): + """Verify that scalar JSON values are parsed but fall through to unstructured redaction.""" + source = '"token=secret123456789"' + redacted = redact_text(source) + assert redacted == '"token=[REDACTED]"' + +def test_redact_literal_prefix_collision(): + """Verify that a plain-text line starting with 't' (but not 'true') is safely handled.""" + source = 'token=secret123456789' + redacted = redact_text(source) + assert redacted == 'token=[REDACTED]' diff --git a/tests/test_repository_branch_coverage_review_schedulers.py b/tests/test_repository_branch_coverage_review_schedulers.py index 3e27d0dfc3..04defb2d3f 100644 --- a/tests/test_repository_branch_coverage_review_schedulers.py +++ b/tests/test_repository_branch_coverage_review_schedulers.py @@ -139,7 +139,9 @@ def test_fix_scheduler_queue_includes_eligible_pr_without_fix_need( "baseRefName": "main", "headRepository": {"nameWithOwner": "owner/repo"}, } - monkeypatch.setattr(fix_scheduler, "fetch_open_prs", lambda *_args: [pr]) + monkeypatch.setattr( + fix_scheduler, "fetch_open_prs", lambda *_args, **_kwargs: [pr] + ) monkeypatch.setattr(fix_scheduler, "same_repository_head", lambda *_args: True) monkeypatch.setattr(fix_scheduler, "needs_autofix", lambda _pr: (False, ())) monkeypatch.setattr( @@ -154,6 +156,8 @@ def test_fix_scheduler_queue_includes_eligible_pr_without_fix_need( repo="owner/repo", pr_number=None, max_prs=10, + scan_window_size=50, + rotation_seed=0, base_branch="main", max_dispatches=1, dry_run=True, diff --git a/tests/test_repository_metadata_reconciliation.py b/tests/test_repository_metadata_reconciliation.py index 2bfc9d1386..17d227d45f 100644 --- a/tests/test_repository_metadata_reconciliation.py +++ b/tests/test_repository_metadata_reconciliation.py @@ -79,12 +79,21 @@ def test_metadata_manifest_declares_exact_casing_and_public_surfaces() -> None: "DiagramWeave": ("diagram-editor", "plantuml"), "semantic-data-portal": ("data-catalog", "semantic-search"), "contextual-orchestrator": ("llm-orchestration", "model-routing"), + "noema": ("control-plane", "oidc"), "mhtml-etl-gateway": ("mhtml", "etl"), "PolicyWeave": ("privacy-policy", "typescript"), "supply-chain-control-plane": ("supply-chain", "rust"), "learning-management-platform": ("learning-management-system", "rust"), "learning-content-studio": ("lcms", "content-authoring"), "learning-record-store": ("learning-record-store", "xapi"), + "bandscope": ("audio-analysis", "rehearsal"), + "saju-caldav": ("caldav", "four-pillars"), + "governance-risk-compliance": ("governance", "grc"), + "metering-billing-platform": ("metering", "billing"), + "learning-interoperability-contracts": ("xapi", "json-schema"), + "litellm-patched-proxy": ("llm-proxy", "supply-chain-security"), + "pingora-gateway": ("reverse-proxy", "rust"), + "Veilpick": ("web-acquisition", "rust"), } assert set(repositories) == set(expected) for repository, required_topics in expected.items(): @@ -132,6 +141,25 @@ def test_require_exact_dict_and_repository_validation() -> None: with pytest.raises(RECONCILER.ManifestError): RECONCILER._validate_repository("Repo", {**valid, field: value}) + assert RECONCILER._validate_repository( + "Repo", desired(homepage=None) + )["homepage"] is None + assert RECONCILER._validate_repository( + "Repo", desired(homepage="https://example.com/docs") + )["homepage"] == "https://example.com/docs" + for homepage in [ + " https://example.com", + "http://example.com", + "not-a-url", + "https://localhost/docs", + "https://127.0.0.1/docs", + "https://service.internal/docs", + ]: + with pytest.raises(RECONCILER.ManifestError, match="homepage"): + RECONCILER._validate_repository( + "Repo", desired(homepage=homepage) + ) + def test_load_manifest_contracts(tmp_path) -> None: """Manifest root schema, ownership, and non-empty fleet scope are enforced.""" @@ -406,12 +434,38 @@ def gh_api(method, endpoint, **kwargs): topics=["new"], deepwiki=True, pages=True, + homepage="https://example.com/docs", ), ) - assert any(call[0] == "PATCH" for call in calls) + repository_patches = [ + call for call in calls if call[0] == "PATCH" and call[1].endswith("/Repo") + ] + assert [call[2]["body"] for call in repository_patches] == [ + { + "description": "new", + "homepage": "https://example.com/docs", + } + ] 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() + RECONCILER.reconcile_repository( + "Repo", + desired( + description="old", + topics=["old"], + deepwiki=True, + homepage="https://example.com/docs", + ), + ) + repository_patches = [ + call for call in calls if call[0] == "PATCH" and call[1].endswith("/Repo") + ] + assert [call[2]["body"] for call in repository_patches] == [ + {"homepage": "https://example.com/docs"} + ] + calls.clear() monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: True) RECONCILER.reconcile_repository( @@ -450,7 +504,7 @@ def gh_api(method, endpoint, **kwargs): 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()) + RECONCILER.reconcile_repository("Repo", desired(homepage=None)) assert [call[0] for call in calls] == ["GET", "GET"] calls.clear() @@ -461,6 +515,29 @@ def gh_api(method, endpoint, **kwargs): assert [call[0] for call in calls] == ["GET", "GET", "GET"] +def test_verify_rejects_homepage_drift(monkeypatch) -> None: + """Verification fails closed when managed homepage state drifts.""" + + def gh_api(method, endpoint, **kwargs): + if endpoint.endswith("/topics"): + return json.dumps({"names": ["python"]}) + return json.dumps( + { + "default_branch": "main", + "description": "Useful product.", + "homepage": "https://wrong.example.com", + } + ) + + monkeypatch.setattr(RECONCILER, "_gh_api", gh_api) + monkeypatch.setattr(RECONCILER, "_deepwiki_badge_exists", lambda *args: False) + monkeypatch.setattr(RECONCILER, "_pages_exists", lambda *args: False) + with pytest.raises(RuntimeError, match="homepage did not converge"): + RECONCILER.verify_repository( + "Repo", desired(homepage="https://example.com/docs") + ) + + def test_parse_args(monkeypatch, tmp_path) -> None: """CLI supports validation and narrow repository selection.""" @@ -574,4 +651,4 @@ def test_module_main_guard(monkeypatch, tmp_path) -> None: ) with pytest.raises(SystemExit) as exc: runpy.run_path(str(SCRIPT), run_name="__main__") - assert exc.value.code == 0 + assert exc.value.code == 0 \ No newline at end of file diff --git a/tests/test_repository_metadata_workflow_pages.py b/tests/test_repository_metadata_workflow_pages.py index 82aa4462f7..5c05dfbe3d 100644 --- a/tests/test_repository_metadata_workflow_pages.py +++ b/tests/test_repository_metadata_workflow_pages.py @@ -2,6 +2,8 @@ from __future__ import annotations +import re + import importlib.util import json from pathlib import Path @@ -40,7 +42,11 @@ def test_metadata_pr_validation_cancels_superseded_head_runs() -> None: concurrency = workflow.split("concurrency:", 1)[1].split("jobs:", 1)[0] assert "group: repository-metadata-reconcile-${{ github.ref }}" in concurrency - assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in concurrency + assert re.search( + r"(?m)^[ \t]+cancel-in-progress:[ \t]+\$\{\{ github\.event_name == 'pull_request' \}\}" + r"[ \t]*$", + concurrency, + ) assert "github.event.pull_request.head.sha" not in concurrency diff --git a/tests/test_required_review_runner_image_contract.py b/tests/test_required_review_runner_image_contract.py index c173716e3e..eb2e109616 100644 --- a/tests/test_required_review_runner_image_contract.py +++ b/tests/test_required_review_runner_image_contract.py @@ -9,28 +9,46 @@ STRIX = Path(".github/workflows/strix.yml") OPENCODE_REVIEW = Path(".github/workflows/opencode-review.yml") NOEMA_REVIEW = Path(".github/workflows/noema-review.yml") +OPENCODE_REVIEW_DISPATCH = Path(".github/workflows/opencode-review-dispatch.yml") class RequiredReviewRunnerImageContract(unittest.TestCase): """Keep required review jobs off the observed starved floating image.""" + def assert_explicit_supported_image(self, path: Path) -> None: + """Require every job runner declaration to pin Ubuntu 24.04.""" + runs_on = { + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip().startswith("runs-on:") + } + self.assertTrue(runs_on) + self.assertEqual(runs_on, {"runs-on: ubuntu-24.04"}) + def test_strix_uses_explicit_supported_image(self) -> None: """Require every Strix job to use explicit Ubuntu 24.04.""" - workflow = STRIX.read_text(encoding="utf-8") - self.assertNotIn("runs-on: ubuntu-latest", workflow) - self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 3) + self.assert_explicit_supported_image(STRIX) def test_opencode_review_uses_explicit_supported_image(self) -> None: """Require every OpenCode Review job to use explicit Ubuntu 24.04.""" - workflow = OPENCODE_REVIEW.read_text(encoding="utf-8") - self.assertNotIn("runs-on: ubuntu-latest", workflow) - self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 5) + self.assert_explicit_supported_image(OPENCODE_REVIEW) def test_noema_review_uses_explicit_supported_image(self) -> None: """Require every Noema Review job to use explicit Ubuntu 24.04.""" - workflow = NOEMA_REVIEW.read_text(encoding="utf-8") - self.assertNotIn("runs-on: ubuntu-latest", workflow) - self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 2) + self.assert_explicit_supported_image(NOEMA_REVIEW) + + def test_opencode_review_dispatch_uses_explicit_supported_image(self) -> None: + """Require every OpenCode Review Dispatch job to use explicit Ubuntu 24.04. + + This is the workflow the required `opencode-review` check's + `repository_dispatch` actually lands on to run the OpenCode CLI and + post the exact-head verdict; a starved floating image here queues + the real review work for hours just as surely as on the required + check itself (see docs/product-technical-gap-baseline.md's + 2026-09-01 entry, whose own "Residual" note flagged this exact + follow-up sweep as still open). + """ + self.assert_explicit_supported_image(OPENCODE_REVIEW_DISPATCH) if __name__ == "__main__": diff --git a/tests/test_required_security_runner_image_contract.py b/tests/test_required_security_runner_image_contract.py index 699e4dde3f..2b48f66251 100644 --- a/tests/test_required_security_runner_image_contract.py +++ b/tests/test_required_security_runner_image_contract.py @@ -14,21 +14,27 @@ class RequiredSecurityRunnerImageContract(unittest.TestCase): """Keep required security jobs off the observed starved floating image.""" def test_security_scan_uses_explicit_supported_image(self) -> None: - """Require every Security Scan job to use explicit Ubuntu 24.04.""" + """Require every Security Scan job to use explicit Ubuntu 24.04. + + 6, not 5: the document-scope-independent Gitleaks PR gate joined the + five existing required security jobs on this image. + """ workflow = SECURITY_SCAN.read_text(encoding="utf-8") self.assertNotIn("runs-on: ubuntu-latest", workflow) - self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 4) + self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 6) def test_sast_semgrep_uses_explicit_supported_image(self) -> None: """Require the SAST Semgrep job to use explicit Ubuntu 24.04. `#1656` removed the sibling `cancel-closed-pr-runs` no-op job (it only duplicated PR-stable workflow concurrency), leaving one runner - job in this workflow instead of two. + job in this workflow instead of two. It is 2, not 1, again after the + `changed-scope` gate job was added to skip doc-only PR scope (org + ruleset 18156473 ignores trigger-level path filters). """ workflow = SAST_SEMGREP.read_text(encoding="utf-8") self.assertNotIn("runs-on: ubuntu-latest", workflow) - self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 1) + self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 2) if __name__ == "__main__": diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index a2a7407fdd..4ab09b1d0c 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -3,7 +3,6 @@ import json import os import re -import shlex import shutil import subprocess import sys @@ -22,6 +21,90 @@ def workflow_text(name: str) -> str: return (REPO_ROOT / ".github" / "workflows" / name).read_text(encoding="utf-8") +# The workflow-level block is the one whose key starts at column zero; job-level +# blocks are indented under ``jobs:``. Anchoring there instead of slicing the text +# before ``permissions:`` makes the search independent of key order, which two +# workflows already need: javascript-coverage-quality-ci.yml and +# repository-metadata-reconcile.yml declare ``permissions:`` above ``concurrency:``, +# and the older slice returned nothing for them and raised IndexError rather than +# reading the block that is plainly there. +WORKFLOW_LEVEL_CONCURRENCY_BLOCK = re.compile( + r"(?m)^concurrency:[ \t]*\n(?P(?:[ \t]+[^\n]*\n)+)" +) + + +def workflow_level_concurrency_group(workflow: str) -> str: + """Return only the workflow-level ``concurrency.group`` value, comments removed. + + Asserting that an expression "appears in the concurrency block" is satisfied by + a comment that merely documents the key while the key itself says something + else, because the block's raw text carries its comments. That is not + hypothetical: the block above this workflow's group explains the key in prose, + so a maintainer quoting the expressions there while another change collapsed + the group to the repository alone would leave every pull request in one group, + cancelling each other, with the contract still green. Slice to the group's own + value so the assertion tests the key rather than the documentation beside it. + """ + block_match = WORKFLOW_LEVEL_CONCURRENCY_BLOCK.search(workflow) + if block_match is None: + raise AssertionError("workflow declares no workflow-level concurrency block") + value: list[str] = [] + collecting = False + for line in block_match.group("body").splitlines(): + if line.strip().startswith("#"): + continue + if not collecting: + if re.match(r"^\s*group:", line): + collecting = True + value.append(line.split("group:", 1)[1]) + continue + if re.match(r"^\s*[A-Za-z][\w-]*:", line): + break + value.append(line) + if not collecting: + raise AssertionError("workflow-level concurrency block declares no group") + head = value[0].strip() + if head.startswith("|"): + # Not represented here, and on 2026-09-07 no workflow uses one: a literal + # block keeps its newlines, so folding it would return a value YAML never + # produces. Refusing is better than returning a plausible wrong string. + raise AssertionError("literal block scalars are not supported for the group key") + if head.startswith(">"): + # Nine of the twenty-nine workflow-level keys are folded, including every + # required review workflow, so this is the majority shape rather than an + # edge case. YAML joins a folded scalar's lines with single spaces, so + # returning the indicator and the raw newlines would make the helper + # disagree with the file's own meaning. Blank lines and more-deeply + # indented lines inside a fold keep their newlines in YAML and are not + # handled here; neither shape occurs in this tree. + return " ".join(part.strip() for part in value[1:] if part.strip()) + return "\n".join(value).strip() + + +def workflow_level_cancels_in_progress(workflow: str) -> bool: + """Return whether the workflow-level block really sets ``cancel-in-progress: true``. + + Anchored to the start of a block line, so a commented-out setting cannot + satisfy it. Substring assertions could: commenting the real line out and + adding ``cancel-in-progress: false`` beside it leaves the searched text in + the file while YAML reads the opposite, and on 2026-09-06 that mutation + passed the whole suite (2958 passed, 0 failed) against ``noema-review.yml``. + A required review workflow that stops cancelling superseded runs keeps every + earlier review alive on each push, which is the queue behaviour this + repository has been trying to remove. + + Kept separate from the group helper on purpose: ``cancel-in-progress`` is a + sibling of ``group``, so it lies outside the value that helper returns and + cannot be covered by moving assertions onto it. + """ + block_match = WORKFLOW_LEVEL_CONCURRENCY_BLOCK.search(workflow) + if block_match is None: + raise AssertionError("workflow declares no workflow-level concurrency block") + return bool( + re.search(r"(?m)^[ \t]+cancel-in-progress:[ \t]+true[ \t]*$", block_match.group("body")) + ) + + def workflow_step(workflow: str, name: str) -> str: """Extract one named workflow step without parsing YAML dynamically.""" step = f" - name: {name}\n" @@ -46,6 +129,20 @@ def test_merge_scheduler_dispatches_one_review_by_default() -> None: ) +def test_scheduler_uses_bounded_run_state_without_cache_lock_claims() -> None: + """Keep each run bounded without treating immutable cache snapshots as locks.""" + workflow = workflow_text("pr-review-merge-scheduler.yml") + + assert workflow.count( + '--admission-state-path "${RUNNER_TEMP}/review-admission/state.json"' + ) == 1 + assert workflow.count("--admission-dispatch-budget") == 1 + assert workflow.count("--admission-sequence \"$GITHUB_RUN_ID\"") == 1 + assert "actions/cache/restore" not in workflow + assert "actions/cache/save" not in workflow + assert "actions/upload-artifact" not in workflow + + def test_organization_readiness_does_not_echo_untrusted_http_method( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -79,49 +176,42 @@ def test_merge_scheduler_rejects_untrusted_stale_timeout_values() -> None: """Dispatch payloads must not smuggle shell syntax into scheduler arguments.""" workflow = workflow_text("pr-review-merge-scheduler.yml") - assert workflow.count("STALE_OPENCODE_MINUTES must contain only decimal digits") == 2 - assert workflow.count("STALE_OPENCODE_MINUTES must be between 1 and 1440") == 4 - assert workflow.count("stale_opencode_minutes=$((10#$STALE_OPENCODE_MINUTES))") == 2 - assert workflow.count('STALE_OPENCODE_MINUTES="$stale_opencode_minutes"') == 2 + assert workflow.count("STALE_OPENCODE_MINUTES must contain only decimal digits") == 1 + assert workflow.count("STALE_OPENCODE_MINUTES must be between 1 and 1440") == 2 + assert workflow.count("stale_opencode_minutes=$((10#$STALE_OPENCODE_MINUTES))") == 1 + assert workflow.count('STALE_OPENCODE_MINUTES="$stale_opencode_minutes"') == 1 -def test_merge_scheduler_deduplicates_unscoped_repository_dispatches() -> None: - """Use stable repository-scoped concurrency keys for unscoped events.""" +def test_merge_scheduler_uses_native_auto_merge_after_required_checks() -> None: + """Do not enqueue a scheduler run after every required workflow completion.""" workflow = workflow_text("pr-review-merge-scheduler.yml") concurrency_contract = workflow.split("concurrency:", 1)[1].split( "permissions:", 1 )[0] - assert "format('org-sweep-{0}', github.repository)" in concurrency_contract + assert "org-sweep" not in concurrency_contract assert "format('repo-dispatch-{0}', github.repository)" in concurrency_contract - assert "format('workflow-run-no-pr-{0}', github.repository)" in concurrency_contract - assert ( - "github.event_name == 'workflow_run' && !github.event.workflow_run.pull_requests[0].number" - in concurrency_contract - ) + assert "workflow_run:" not in workflow.split("workflow_call:", 1)[0] + assert "github.event.workflow_run" not in concurrency_contract assert "github.event_name == 'repository_dispatch' && github.run_id" not in ( concurrency_contract ) - assert "cancel-in-progress: ${{" in concurrency_contract + # Anchored, not a substring: this workflow's value is an expression rather + # than a constant, so it cannot use the boolean helper, but a commented-out + # setting must not satisfy it either. + assert re.search(r"(?m)^[ \t]+cancel-in-progress:[ \t]+\$\{\{", concurrency_contract) assert "github.event_name == 'repository_dispatch'" in concurrency_contract def test_merge_scheduler_provides_same_repository_dispatch_credential() -> None: """Guard the runner-token dispatch credential for central review workflows. - The OpenCode app installation has no Actions permission and no - PR_REVIEW_MERGE_TOKEN / OPENCODE_APPROVE_TOKEN PAT is configured, so before - this credential existed the org sweep deadlocked every PR needing current-head - review evidence with "no cross-repository repository-dispatch credential". The - scheduler and the sweep both run inside ContextualWisdomLab/.github — the same - repository the required workflows are dispatched on — so the runner's own - github.token (actions: write) must be passed through SCHEDULER_DISPATCH_TOKEN - in BOTH jobs; the scheduler only uses it when GITHUB_REPOSITORY equals the - dispatch repository. + The scheduler runs inside the same repository as the central required + workflows, so its repository-scoped token is the single dispatch credential. """ workflow = workflow_text("pr-review-merge-scheduler.yml") - assert workflow.count("SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}") == 2 + assert workflow.count("SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}") == 1 def test_targeted_scheduler_dispatch_is_allowlisted_and_exact_pr_scoped() -> None: @@ -211,6 +301,258 @@ def test_privileged_review_retries_use_default_branch_repository_dispatch() -> N assert '"gh",\n "workflow",\n "run"' not in autofix_scheduler +def test_privileged_review_dispatch_coalesces_superseded_runs_before_admission() -> None: + """A superseded dispatch must be cancelled while queued, not after it takes a runner. + + ``opencode-review-dispatch.yml`` carried its concurrency group only on the + long ``opencode-review-target`` job. A job-level group is not evaluated + while the whole run waits behind the organization job ceiling, so two + dispatches for one pull request each waited hours and each was allocated a + runner before the older one could be discarded. Measured on 2026-09-06: + four of the five dispatch runs that passed ``validate-pr-metadata`` were + then rejected by the privileged metadata check because the head had moved + while they queued, every one of them after ``coverage-source-tree`` and + ``coverage-evidence`` had already run. + + The workflow-level group is keyed by the dispatched pull request, matching + ``codeql-scan-dispatch.yml``'s workflow-level group and the job-level group + this workflow keeps for the review job itself. + """ + workflow = workflow_text("opencode-review-dispatch.yml") + header = workflow.split("permissions:", 1)[0] + concurrency_contract = header.split("concurrency:", 1)[1] + group_value = workflow_level_concurrency_group(workflow) + + assert re.search(r"(?m)^concurrency:", header) + assert "opencode-review-dispatch-" in group_value + assert ( + "github.event.client_payload.target_repository || github.repository" + in group_value + ) + assert "github.event.client_payload.pr_number || github.run_id" in group_value + assert workflow_level_cancels_in_progress(workflow) + assert "github.event.client_payload.pr_head_sha" not in concurrency_contract + assert re.search(r"(?m)^ concurrency:", workflow) + + +@pytest.mark.parametrize( + ("workflow_name", "group_prefix"), + ( + ("agent-mention-opencode-dispatch.yml", "agent-mention-opencode-"), + ("agent-mention-noema-dispatch.yml", "agent-mention-noema-"), + ), +) +def test_agent_mention_dispatch_coalesces_while_queued( + workflow_name: str, group_prefix: str +) -> None: + """A superseded agent mention must be discarded before it holds a queue slot. + + Both mention dispatchers carried the same defect + ``opencode-review-dispatch.yml`` carried before #1958: the group sat on the + single ``validate-and-forward`` job, and a job-level group is not evaluated + while the run waits behind the organization job ceiling. Measured on the + review dispatcher over the 39.7 hours ending 2026-09-06T12:41Z, 23 pairs of + runs for one pull request overlapped -- the older run was still open when its + successor arrived -- and none was coalesced; the five that ended + ``cancelled`` were cancelled between 0.7 and 2.9 hours after the newer run + was created, which is a sweep, not concurrency. + + The group moves to workflow level and is not duplicated on the job. Every + workflow here that keys a group at both levels (``strix.yml``, + ``opencode-review-dispatch.yml``) gives the two levels different names, + because a job that requests the group its own run already holds waits on + itself. + """ + workflow = workflow_text(workflow_name) + header = workflow.split("permissions:", 1)[0] + group = workflow_level_concurrency_group(workflow) + + assert re.search(r"(?m)^concurrency:", header) + # Read the group's value, not the block: the comment above these keys quotes + # the very expressions asserted here, so a raw-block assertion would survive + # the key being collapsed. That is the hole #1970 closed. + assert group.strip().startswith(group_prefix) + assert "github.event.client_payload.target_repository" in group + assert "github.event.client_payload.pr_number || github.run_id" in group + # ``cancel-in-progress`` is a sibling key, so it is outside the group value. + # Anchor it to its own line at the block's indent; a comment starts with + # ``#`` and cannot satisfy this. + assert re.search(r"(?m)^ cancel-in-progress: true$", header) + # ``\s`` also matches the newline before a column-0 key, so anchor the + # job-level search on horizontal whitespace only. + assert not re.search(r"(?m)^[ \t]+concurrency:", workflow) + + +def test_agent_mention_router_keeps_its_two_distinct_job_groups() -> None: + """The router must not be hoisted: its two jobs need different groups. + + ``agent-mention-router.yml`` runs a per-issue local route that supersedes + itself and an organization-wide sweep that must never be cancelled midway. + A workflow carries at most one workflow-level group, so hoisting either one + would silently give the sweep the route's ``cancel-in-progress: true`` and + let a later comment kill a sweep that is part way through the organization. + """ + workflow = workflow_text("agent-mention-router.yml") + + assert not re.search(r"(?m)^concurrency:", workflow) + assert ( + "group: review-agent-mention-router-local-${{ github.repository }}" + in workflow + ) + assert "group: review-agent-mention-router-sweep-${{ github.repository }}" in workflow + + sweep = workflow.split("sweep-organization-agent-mentions:", 1)[1] + # Anchored on the sweep JOB block: this router declares no workflow-level + # concurrency, so the sibling helper would raise rather than read it. + assert re.search( + r"(?m)^[ \t]+cancel-in-progress:[ \t]+false[ \t]*$", + sweep.split("steps:", 1)[0], + ) + +def test_concurrency_group_slice_ignores_the_comment_that_documents_it() -> None: + """A comment quoting the key must not satisfy an assertion about the key. + + This is the negative control for ``workflow_level_concurrency_group``. The + synthetic workflow below is exactly the shape that defeated the previous + contract: the real group is collapsed to the repository alone, so every pull + request in that repository shares one group and they cancel each other, while + a comment directly above still quotes both expressions the contract looks for. + Reading the raw block finds them; reading the group's value does not. + """ + defeated = textwrap.dedent( + """\ + name: Example + on: + repository_dispatch: + concurrency: + # Key: github.event.client_payload.target_repository || github.repository + # with github.event.client_payload.pr_number || github.run_id + group: opencode-review-dispatch-${{ github.repository }} + cancel-in-progress: true + permissions: + contents: read + """ + ) + raw_block = defeated.split("permissions:", 1)[0].split("concurrency:", 1)[1] + group_value = workflow_level_concurrency_group(defeated) + + assert "github.event.client_payload.pr_number || github.run_id" in raw_block + assert "github.event.client_payload.pr_number || github.run_id" not in group_value + assert "github.event.client_payload.target_repository" not in group_value + assert "opencode-review-dispatch-${{ github.repository }}" in group_value + + +def test_concurrency_group_slice_reads_a_folded_multi_line_key() -> None: + """The real key is a folded block, so the slice must join its continuation lines.""" + folded = textwrap.dedent( + """\ + concurrency: + group: >- + opencode-review-dispatch-${{ + github.event.client_payload.target_repository || github.repository }}-${{ + github.event.client_payload.pr_number || github.run_id }} + cancel-in-progress: true + permissions: + contents: read + """ + ) + group_value = workflow_level_concurrency_group(folded) + + assert ( + "github.event.client_payload.target_repository || github.repository" + in group_value + ) + assert "github.event.client_payload.pr_number || github.run_id" in group_value + assert "cancel-in-progress" not in group_value + + +def test_concurrency_helpers_read_the_block_when_permissions_comes_first() -> None: + """Key order must not decide whether the contract can see the block. + + The earlier helper sliced the text before ``permissions:`` and then split on + ``concurrency:``. That works only when ``concurrency:`` is declared first. Two + workflows in this repository declare ``permissions:`` above it -- + javascript-coverage-quality-ci.yml and repository-metadata-reconcile.yml -- + and for those the slice was empty, so the helper raised ``IndexError`` instead + of reading the block that is plainly there. Anchoring at column zero makes the + order irrelevant. + """ + permissions_first = textwrap.dedent( + """\ + name: Example + permissions: + contents: read + concurrency: + group: example-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: + build: + runs-on: ubuntu-latest + """ + ) + + assert ( + workflow_level_concurrency_group(permissions_first) + == "example-${{ github.repository }}-${{ github.event.pull_request.number }}" + ) + assert workflow_level_cancels_in_progress(permissions_first) + + +def test_concurrency_helpers_name_a_missing_block_instead_of_index_error() -> None: + """A workflow with no top-level block must fail with a sentence, not ``IndexError``. + + ``IndexError: list index out of range`` names neither the workflow nor the + contract it broke, so a reader has to reconstruct both from the traceback. + """ + no_block = "name: Example\njobs:\n build:\n runs-on: ubuntu-latest\n" + + for helper in (workflow_level_concurrency_group, workflow_level_cancels_in_progress): + with pytest.raises(AssertionError, match="no workflow-level concurrency block"): + helper(no_block) + + +def test_cancel_in_progress_assertion_rejects_a_commented_out_setting() -> None: + """The negative control for ``workflow_level_cancels_in_progress``. + + A substring test for ``cancel-in-progress: true`` is satisfied by a comment + that quotes it. On 2026-09-06 that exact mutation -- comment out the real line + in noema-review.yml, add ``cancel-in-progress: false`` beneath it -- passed the + whole suite (2958 passed, 0 failed) while every push to a pull request stopped + cancelling its own superseded run. Anchoring to the start of a block line is + what closes it. + """ + quoted_but_disabled = textwrap.dedent( + """\ + concurrency: + group: example-${{ github.repository }}-${{ github.event.pull_request.number }} + # cancel-in-progress: true + cancel-in-progress: false + """ + ) + + assert "cancel-in-progress: true" in quoted_but_disabled + assert not workflow_level_cancels_in_progress(quoted_but_disabled) + + +def test_required_opencode_dispatch_does_not_wait_on_merge_scheduler() -> None: + """Dispatch review execution directly so polling cannot starve its producer.""" + workflow = workflow_text("opencode-review.yml") + dispatch = workflow_step(workflow, "Request current-head OpenCode review execution") + + assert 'event_type:"opencode-review"' in dispatch + assert 'event_type:"merge-scheduler"' not in dispatch + assert 'required_run_id:$required_run_id' in dispatch + for field in ( + "target_repository", + "pr_number", + "pr_base_ref", + "pr_base_sha", + "pr_head_ref", + "pr_head_sha", + ): + assert f"{field}:${field}" in dispatch + + def test_no_central_workflow_exposes_branch_selected_manual_dispatch() -> None: """Every central manual entrypoint must load code from the default branch.""" workflow_files = sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")) @@ -225,65 +567,77 @@ def test_no_central_workflow_exposes_branch_selected_manual_dispatch() -> None: def test_required_pull_request_workflows_cancel_superseded_runs() -> None: """Ensure required pull-request workflows cancel obsolete executions.""" for filename in ( - "close-empty-pr.yml", "codeql-pr.yml", "noema-review.yml", "opencode-review.yml", - "osv-scanner-pr.yml", "security-scan.yml", - "scorecard-pr.yml", ): workflow = workflow_text(filename) concurrency_contract = workflow.split("concurrency:", 1)[1].split( "permissions:", 1 )[0] + group_value = workflow_level_concurrency_group(workflow) assert "concurrency:" in workflow - assert "github.event.pull_request.base.repo.full_name" in concurrency_contract - assert "github.repository" in concurrency_contract + assert "github.event.pull_request.base.repo.full_name" in group_value + assert "github.repository" in group_value assert "github.event.pull_request.number" in workflow - if filename != "noema-review.yml": - assert "cancel-in-progress: true" in workflow - if filename in { - "close-empty-pr.yml", - "security-scan.yml", - }: + assert re.search(r"(?m)^concurrency:", workflow) + assert workflow_level_cancels_in_progress(workflow) + if filename == "security-scan.yml": assert ( - "github.event_name == 'pull_request_target'" in concurrency_contract - or ("github.event_name == 'pull_request'" in concurrency_contract) + "github.event_name == 'pull_request_target'" in group_value + or ("github.event_name == 'pull_request'" in group_value) ) elif filename == "opencode-review.yml": - assert "opencode-review-bootstrap-" in concurrency_contract - # Unlike the other required pull-request workflows below, this - # group is deliberately also scoped by exact head SHA: a - # delayed, out-of-order run for an older head must not be able - # to cancel the authoritative run already active for a newer - # head (Devin Review on `#1568`). Same-head events still share - # one group and can still cancel each other. - assert ( - "github.event.pull_request.head.sha || github.run_id" - in concurrency_contract - ) + assert "required-opencode-review-${{" in group_value + assert "outputs.admitted == 'true'" in workflow elif filename == "noema-review.yml": + assert not re.search(r"(?m)^ concurrency:", workflow) assert "github.event.workflow_run" not in concurrency_contract - assert "noema-review-${{" in concurrency_contract - assert "github.event_name" not in concurrency_contract.split( - "cancel-in-progress:", 1 - )[0] - assert "github.event.action == 'synchronize'" in concurrency_contract - assert "github.event.action == 'closed'" in concurrency_contract + assert "required-noema-review-${{" in group_value + assert "outputs.admitted == 'true'" in workflow else: - if filename in {"codeql-pr.yml", "osv-scanner-pr.yml", "scorecard-pr.yml"}: - assert "github.event_name == 'pull_request'" in concurrency_contract + if filename == "codeql-pr.yml": + assert "github.event_name == 'pull_request'" in group_value else: - assert ( - "github.event_name == 'pull_request_target'" in concurrency_contract - ) - if filename not in {"noema-review.yml", "opencode-review.yml"}: - assert "github.event.pull_request.head.sha" not in concurrency_contract + assert "github.event_name == 'pull_request_target'" in group_value + assert "github.event.pull_request.head.sha" not in concurrency_contract assert "format('pr-{0}-{1}'" not in concurrency_contract +def test_pr_quality_workflows_isolate_concurrency_by_repository_and_pr() -> None: + """Quality runs from different repositories must never share a PR queue.""" + groups = { + "agent-mention-router-quality-ci.yml": "agent-mention-router-quality", + "cloudflare-dns.yml": "cloudflare-dns", + "javascript-coverage-quality-ci.yml": "javascript-coverage-quality", + "trusted-uv-materializer-quality-ci.yml": ( + "trusted-uv-materializer-quality" + ), + } + + for filename, group_name in groups.items(): + workflow = workflow_text(filename) + concurrency = workflow.split("concurrency:", 1)[1].split("jobs:", 1)[0] + assert ( + f"group: {group_name}-${{{{ github.repository }}}}-" + "${{ github.event.pull_request.number || github.ref }}" + ) in concurrency + if filename == "cloudflare-dns.yml": + # Anchored like the ``true`` contracts below: a commented-out setting + # must not satisfy this either, and this workflow deliberately cancels + # only for pull requests, so its value is an expression rather than a + # constant. + assert re.search( + r"(?m)^[ \t]+cancel-in-progress:[ \t]+\$\{\{ github\.event_name ==" + r" 'pull_request' \}\}[ \t]*$", + concurrency, + ) + else: + assert workflow_level_cancels_in_progress(workflow) + + def test_central_semgrep_logs_every_finding_and_distinguishes_engine_failure() -> None: """Keep Semgrep finding output distinct from scanner-engine failures.""" workflow = workflow_text("sast-semgrep.yml") @@ -334,42 +688,48 @@ def test_central_semgrep_binds_pr_scans_and_sarif_to_the_exact_head() -> None: ) -def test_strix_serializes_provider_evidence_per_repository() -> None: - """Serialize Strix per repository so shared provider keys are not rate-limited. +def test_strix_serializes_provider_evidence_per_repository_and_pr() -> None: + """Scope Strix workflow admission per repository AND PR. + + History: from 2026-08-24 through 2026-09-03 the concurrency group was + deliberately repository-wide (not PR-scoped) because PR-scoping is what + caused a real litellm.RateLimitError storm against the shared NVIDIA NIM + key on 2026-08-23/24 -- sibling PRs scanned concurrently, each retrying the + shared key three times, producing fail-closed gate failures on every open + PR. That repository-wide scoping fixed the storm but starved cross-PR + Strix evidence within the same repository instead (a different PR's scan + always queued behind whichever scan was already running there). - 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. + Restored to PR-scoped on explicit owner authorization (2026-09-03) after + confirming NVIDIA_NIM_API_KEY and NVIDIA_NIM_API_KEY_SUB have independent + rate limits rather than a shared pool. The workflow-level group now retires + superseded runs before runner admission, including runs still blocked by + the organization-wide job ceiling. Native and dispatched evidence share + one group; non-PR events use a unique run id. """ workflow = workflow_text("strix.yml") concurrency_contract = workflow.split("concurrency:", 1)[1].split( "permissions:", 1 )[0] - - 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 - assert ( - "format('{0}-{1}', github.event_name, github.event.client_payload.target_repository || " - "github.event.pull_request.base.repo.full_name || github.repository)" - ) in concurrency_contract - assert ( - "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 + strix_job = workflow.split("\n strix:\n", 1)[1] + + group_value = workflow_level_concurrency_group(workflow) + + assert re.search(r"(?m)^concurrency:", workflow) + assert "needs: [changed-scope, admit-current-head]" in strix_job + assert "needs.admit-current-head.outputs.admitted == 'true'" in strix_job + assert "strix-security-scan-${{" in group_value + assert "github.event.pull_request.base.repo.full_name" in group_value + assert "github.event.client_payload.target_repository" in group_value + assert "github.event.pull_request.number" in group_value + assert "github.event.client_payload.pr_number" in group_value + assert "github.run_id" in group_value 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 workflow_level_cancels_in_progress(workflow) + assert " concurrency:" not in strix_job.split(" permissions:", 1)[0] assert "queue: max" not in workflow - assert workflow.index("cancel-superseded-pr-runs:") < workflow.index("concurrency:") + assert workflow.index("admit-current-head:") < workflow.index("\n strix:\n") cleanup_job = workflow.split(" cancel-superseded-pr-runs:", 1)[1].split( " strix:", 1 )[0] @@ -440,14 +800,16 @@ def test_strix_cleanup_uses_pr_metadata_when_custom_title_is_absent() -> None: assert result.stdout.splitlines() == ["1"] -def _run_strix_cleanup(tmp_path: Path, pull_states: list[dict[str, object]]) -> str: +def _run_strix_cleanup( + tmp_path: Path, pull_states: list[dict[str, object]], *, action: str = "synchronize" +) -> 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", + "Cancel queued and running scans for superseded or inactive pull requests", ) run_block = step.split(" run: |\n", 1)[1].split("\n strix:", 1)[0] script = textwrap.dedent(run_block) @@ -494,7 +856,7 @@ def _run_strix_cleanup(tmp_path: Path, pull_states: list[dict[str, object]]) -> "TARGET_REPOSITORY": "owner/repo", "TARGET_PR_NUMBER": "7", "TARGET_PR_HEAD_SHA": "current", - "PR_ACTION": "synchronize", + "PR_ACTION": action, "CURRENT_RUN_ID": "999", } subprocess.run(["bash", "-c", script], env=env, check=True, capture_output=True, text=True) @@ -521,10 +883,10 @@ def test_strix_cleanup_revalidates_after_selection_before_cancellation( calls = _run_strix_cleanup( tmp_path, [ - {"state": "open", "head": {"sha": "current"}}, - {"state": "open", "head": {"sha": "newer"}}, + {"state": "open", "draft": False, "head": {"sha": "current"}}, + {"state": "open", "draft": False, "head": {"sha": "newer"}}, ] - + [{"state": "open", "head": {"sha": "newer"}}] * 4, + + [{"state": "open", "draft": False, "head": {"sha": "newer"}}] * 4, ) assert "actions/runs?status=queued" in calls @@ -532,19 +894,51 @@ def test_strix_cleanup_revalidates_after_selection_before_cancellation( assert "/actions/runs/100/force-cancel" not in calls +def test_strix_draft_transition_cancels_current_scan(tmp_path: Path) -> None: + """A verified Draft transition retires the current expensive Strix run.""" + calls = _run_strix_cleanup( + tmp_path, + [{"state": "open", "draft": True, "head": {"sha": "current"}}] * 6, + action="converted_to_draft", + ) + + assert "/actions/runs/100/cancel" in calls + + +def test_pr_keyed_scan_workflows_pin_cancellation_as_a_value() -> None: + """Pin `cancel-in-progress` for the two PR-keyed scans that only had presence. + + Both appear in ``test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs``, + but in the branch that asserts the key is *present* rather than what it says. + That branch is shaped by ``pr-review-merge-scheduler.yml``, whose value is + deliberately an expression over ``github.event_name``, so the loop cannot + assert a constant for everyone in it. Nothing else read the flag: flipping + either to ``false`` left the whole suite green (2968 passed, 0 failed, + measured 2026-09-06). + + Kept out of ``test_required_pull_request_workflows_cancel_superseded_runs`` + because that loop ends by requiring a ``github.event_name`` discriminator in + the group, and these two key on + ``pull_request.number || github.ref`` with no event-name term. Adding them + there would need a branch that asserts nothing. + """ + for filename in ("python-security.yml", "sast-semgrep.yml"): + workflow = workflow_text(filename) + group_value = workflow_level_concurrency_group(workflow) + + assert workflow_level_cancels_in_progress(workflow) + assert "github.event.pull_request.number" in group_value + assert "github.event_name" not in group_value + + def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() -> None: """Close events should cancel old runs without starting expensive jobs.""" workflows = ( - "close-empty-pr.yml", "codeql-pr.yml", "noema-review.yml", - "osv-scanner-pr.yml", "pr-review-merge-scheduler.yml", "python-security.yml", "sast-semgrep.yml", - "sbom-generation.yml", - "scorecard-pr.yml", - "secret-scan.yml", "security-scan.yml", "strix.yml", ) @@ -555,7 +949,7 @@ 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 "Cancel queued and running scans for superseded or inactive pull requests" in workflow assert ( "secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN " "|| github.token" @@ -576,7 +970,7 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - )[0] 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 + assert "Cancel queued and running Noema reviews for the inactive pull request" in workflow assert "leaving runs unchanged" in workflow cleanup_job = workflow.split(" cancel-closed-pr-runs:", 1)[1].split( " noema-review:", 1 @@ -585,15 +979,10 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - assert "actions/checkout" not in cleanup_job assert "cleanup skipped" not in cleanup_job elif filename in { - "close-empty-pr.yml", "codeql-pr.yml", - "osv-scanner-pr.yml", "pr-review-merge-scheduler.yml", "python-security.yml", "sast-semgrep.yml", - "sbom-generation.yml", - "scorecard-pr.yml", - "secret-scan.yml", "security-scan.yml", }: assert "cancel-closed-pr-runs:" not in workflow @@ -602,10 +991,14 @@ def test_pull_request_close_events_cancel_superseded_runs_without_heavy_jobs() - )[0] assert "github.event.pull_request.number" in concurrency_contract assert "github.event.pull_request.head.sha" not in concurrency_contract - assert "cancel-in-progress:" in concurrency_contract + assert re.search( + r"(?m)^[ \t]+cancel-in-progress:[ \t]+\S", concurrency_contract + ) else: raise AssertionError(f"unclassified close-event workflow: {filename}") assert "github.event.action != 'closed'" in workflow + if filename in {"noema-review.yml", "strix.yml"}: + assert "github.event.action != 'converted_to_draft'" in workflow opencode_bootstrap = workflow_text("opencode-review.yml") assert "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, closed]" in ( @@ -615,28 +1008,27 @@ 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 "Keep provider-backed scans serial per repository" in strix_workflow + # Strix admits the live head before same-PR cancellation while cleanup stays + # outside that queue so synchronize and close events can retire old work. + assert "admit-current-head:" in strix_workflow + assert "skipping stale evidence" in strix_workflow + assert workflow_level_cancels_in_progress(strix_workflow) -def test_close_empty_pr_metadata_lookup_retries_and_fails_open() -> None: - """Retry invalid close-event metadata and leave the PR open on uncertainty.""" - workflow = workflow_text("close-empty-pr.yml") +def test_merge_scheduler_owns_empty_pr_cleanup_without_checkout() -> None: + """Keep empty-PR cleanup in the existing metadata-only scheduler job.""" + workflow = workflow_text("pr-review-merge-scheduler.yml") + scheduler = workflow_step(workflow, "Inspect PR review and merge queue") - assert "gh_api_json_with_retry()" in workflow - assert "jq -e type" in workflow - assert "did not return valid JSON; retrying" in workflow - assert "did not return valid JSON after 4 attempts" in workflow - assert "leaving it open because metadata could not be read" in workflow - assert "exit 0" in workflow + assert not (REPO_ROOT / ".github/workflows/close-empty-pr.yml").exists() + assert "pr_review_merge_scheduler.py" in scheduler + assert "actions/checkout" not in workflow -def test_cancelled_review_workflow_runs_do_not_spawn_more_queue_work() -> None: - """Prevent cancelled review runs from creating follow-up queue work.""" +def test_review_workflow_completions_do_not_spawn_scheduler_runs() -> None: + """Required checks rely on GitHub auto-merge instead of a follow-up workflow.""" workflow = workflow_text("pr-review-merge-scheduler.yml") - assert "github.event.workflow_run.conclusion != 'cancelled'" in workflow + assert "github.event.workflow_run" not in workflow def test_required_workflow_trusted_source_refs_are_not_input_controlled() -> None: @@ -669,19 +1061,23 @@ def test_required_workflow_trusted_source_refs_are_not_input_controlled() -> Non def test_noema_triggers_preserve_standalone_pull_request_review() -> None: """Noema reviews PRs independently of the other review workflows.""" workflow = workflow_text("noema-review.yml") - concurrency_contract = workflow.split("permissions:", 1)[0] + noema_job = workflow.split("\n noema-review:\n", 1)[1] + concurrency_contract = workflow.split("\nconcurrency:\n", 1)[1].split( + "\npermissions:\n", 1 + )[0] assert "workflow_run:" not in concurrency_contract assert "github.event.workflow_run" not in workflow assert "github.event.pull_request.number" in concurrency_contract assert "github.event.client_payload.pr_number" in concurrency_contract - assert "noema-review-${{" in concurrency_contract + assert "required-noema-review-${{" in concurrency_contract assert "github.event_name" not in concurrency_contract.split( "cancel-in-progress:", 1 )[0] - assert "github.event.action == 'synchronize'" in concurrency_contract - assert "github.event.action == 'closed'" in concurrency_contract - assert "cancel-in-progress: true" not in concurrency_contract + assert workflow_level_cancels_in_progress(workflow) + assert re.search(r"(?m)^concurrency:", workflow) + assert not re.search(r"(?m)^ concurrency:", workflow) + assert "needs.admit-current-head.outputs.admitted == 'true'" in noema_job assert '[ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]' in workflow @@ -961,17 +1357,17 @@ def test_noema_and_scheduler_trusted_checkouts_use_static_main() -> None: assert "INPUT_CANONICAL_REF" not in workflow -def test_unassociated_review_workflow_runs_do_not_scan_the_whole_pr_queue() -> None: - """Avoid scanning every PR when a workflow run has no associated pull request.""" +def test_merge_scheduler_has_no_workflow_run_trigger() -> None: + """Required-check completion must not create another Actions run.""" workflow = workflow_text("pr-review-merge-scheduler.yml") - assert "github.event.workflow_run.pull_requests[0].number" in workflow + assert "workflow_run:" not in workflow.split("workflow_call:", 1)[0] def test_review_events_can_dispatch_after_threads_are_resolved() -> None: """Let the scheduler dispatch OpenCode when a review event clears its last blocker.""" workflow = workflow_text("pr-review-merge-scheduler.yml") - scan_job = workflow.split(" scan-pr-queue:", 1)[1].split(" org-queue-sweep:", 1)[0] + scan_job = workflow.split(" scan-pr-queue:", 1)[1] assert "github.event_name == 'pull_request_review'" in scan_job.split( "TRIGGER_REVIEWS:", 1 @@ -979,17 +1375,9 @@ def test_review_events_can_dispatch_after_threads_are_resolved() -> None: def test_scan_pr_queue_has_a_bounded_runtime() -> None: - """scan-pr-queue must not fall back to GitHub's 360-minute platform default. - - Without a job-level timeout-minutes, a stuck run (rate-limited GitHub API, - a hung gh invocation) can occupy a shared runner for up to six hours, - contributing to org-wide Actions capacity saturation. The bound must be - shorter than org-queue-sweep's timeout-minutes: 60, since scan-pr-queue - only scans this one repository's queue while org-queue-sweep walks every - target repository in the organization. - """ + """Keep one repository-local scan below GitHub's platform timeout.""" workflow = workflow_text("pr-review-merge-scheduler.yml") - scan_job = workflow.split(" scan-pr-queue:", 1)[1].split(" org-queue-sweep:", 1)[0] + scan_job = workflow.split(" scan-pr-queue:", 1)[1] match = re.search(r"^ timeout-minutes: (\d+)$", scan_job, flags=re.MULTILINE) assert match is not None, "scan-pr-queue must declare a job-level timeout-minutes" @@ -998,580 +1386,12 @@ def test_scan_pr_queue_has_a_bounded_runtime() -> None: assert scan_timeout < 60 -def test_org_queue_sweep_covers_target_repositories_on_a_heartbeat() -> None: - """Guard the org-wide approved-PR fallback sweep contract. - - Target repositories only receive scheduler runs on PR events, so a PR that - becomes mergeable after its last event sits approved-but-unmerged forever. - The sweep job must exist, run only from the central repository on its own - cron, use a cross-repository mutation credential (never the repository - github.token silently), skip the central repository itself, and fail with a - visible reason when it cannot mutate sibling repositories. The sweep runs - hourly so an approval that lands after a PR's last event is - auto-updated/merged promptly instead of idling indefinitely. Its cron has a - distinct concurrency key from the separate scan-pr-queue heartbeat, and the - job has enough runtime headroom to finish a complete organization walk. - """ - workflow = workflow_text("pr-review-merge-scheduler.yml") - - assert "org-queue-sweep:" in workflow - assert '- cron: "0 * * * *"' in workflow - assert "github.repository == 'ContextualWisdomLab/.github'" in workflow - assert "github.event.schedule == '0 * * * *'" in workflow - assert "github.event.client_payload.org_sweep == true" in workflow - assert ( - "github.event_name == 'schedule' && format('schedule-{0}', " - "github.event.schedule)" - ) in workflow - org_sweep_header = workflow.split(" org-queue-sweep:", 1)[1].split( - " permissions:", 1 - )[0] - assert "timeout-minutes: 60" in org_sweep_header - for setting in ( - "ORG_SWEEP_TRIGGER_REVIEWS", - "ORG_SWEEP_ENABLE_AUTO_MERGE", - "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 != '0 * * * *'" 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 - assert '"pull_request" or .event == "pull_request_target"' in workflow - assert "$current_pr_head == null or .head_sha != $current_pr_head" in workflow - assert ".head_sha != $current_default_sha" in workflow - assert "classified as not matching an open PR or default-branch Current HEAD" in workflow - assert '.current_head // "closed-or-no-open-pr"' in workflow - assert '.current_head // \\"closed-or-no-open-pr\\"' not in workflow - assert "select($current_pr_heads[$head_key] == null)" in workflow - revalidate_script = ( - REPO_ROOT / "scripts" / "ci" / "revalidate_queue_cancellation.sh" - ).read_text(encoding="utf-8") - assert "Could not cancel ${cancellation_mode} run" in revalidate_script - 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 - assert "org_review_dispatches_used=0" in workflow - assert "org_stacked_review_dispatches_used=0" in workflow - assert "org_branch_updates_used=0" in workflow - assert 'review_dispatch_limit=$((ORG_SWEEP_REVIEW_DISPATCH_LIMIT - org_review_dispatches_used))' in workflow - assert 'stacked_review_dispatch_limit=$((ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT - org_stacked_review_dispatches_used))' in workflow - assert 'branch_update_limit=$((ORG_SWEEP_BRANCH_UPDATE_LIMIT - org_branch_updates_used))' in workflow - assert '--review-dispatch-limit "$review_dispatch_limit"' in workflow - assert '--stacked-review-dispatch-limit "$stacked_review_dispatch_limit"' in workflow - assert '--branch-update-limit "$branch_update_limit"' in workflow - 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 - - -def test_org_queue_sweep_superseded_run_log_filter_executes() -> None: - """The Current-HEAD cancellation evidence must be valid jq, not just valid Bash.""" - jq = shutil.which("jq") - if jq is None: - pytest.skip("jq is required for the executable workflow filter regression test") - - workflow = workflow_text("pr-review-merge-scheduler.yml") - jq_line = next( - line.strip() - for line in workflow.splitlines() - if "closed-or-no-open-pr" in line and "jq -r" in line - ) - jq_filter = shlex.split(jq_line)[2] - payload = [ - { - "id": 42, - "name": "Required OpenCode Review", - "status": "in_progress", - "event": "pull_request_target", - "head_branch": "old-head", - "run_head": "deadbeef", - "current_head": None, - } - ] - - result = subprocess.run( - [jq, "-r", jq_filter], - input=json.dumps(payload), - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - assert "classified_head=closed-or-no-open-pr" in result.stdout - - -def _extract_org_sweep_rotation_snippet(workflow: str) -> str: - """Return only the rotation-offset bash block, without the surrounding - `gh api`/dispatch logic that would require live network credentials.""" - - start_marker = " sweep_target_count=${#sweep_targets[@]}\n" - end_marker = 'rotation tick ${ORG_SWEEP_ROTATION_INDEX})."\n' - start = workflow.index(start_marker) - end = workflow.index(end_marker, start) + len(end_marker) - return textwrap.dedent(workflow[start:end]) - - -def test_org_queue_sweep_rotation_offset_is_deterministic_and_reorders_targets() -> None: - """Rotating the sweep walk order must preserve every target and only reorder them.""" - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_snippet(workflow) - - for rotation_index, expected_first in ( - ("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 - ): - script = ( - "sweep_targets=($'repo-a\\tmain' $'repo-b\\tmain' $'repo-c\\tmain' " - "$'repo-d\\tmain' $'repo-e\\tmain')\n" - + snippet - + '\nprintf "%s\\n" "${sweep_targets[@]}"\n' - ) - result = subprocess.run( - ["bash", "-euo", "pipefail", "-c", script], - env={**os.environ, "ORG_SWEEP_ROTATION_INDEX": rotation_index}, - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr - rotated = [ - line.split("\t")[0] - for line in result.stdout.strip().splitlines() - if "\t" in line - ] - assert len(rotated) == 5 - assert set(rotated) == {"repo-a", "repo-b", "repo-c", "repo-d", "repo-e"} - assert rotated[0] == expected_first, (rotation_index, result.stdout) - - -def test_org_queue_sweep_rotation_offset_is_safe_with_no_targets() -> None: - """An org with no sweepable repositories must not crash the rotation arithmetic.""" - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_snippet(workflow) - script = "sweep_targets=()\n" + snippet - result = subprocess.run( - ["bash", "-euo", "pipefail", "-c", script], - env={**os.environ, "ORG_SWEEP_ROTATION_INDEX": "3"}, - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr - assert "starting at rotation offset 0" in result.stdout - - -def _extract_org_sweep_rotation_default_snippet(workflow: str) -> str: - """Return only the wall-clock-default/validation block for the rotation index, - without the surrounding `gh api` calls that would require network credentials.""" - - start_marker = " if [ -z \"${ORG_SWEEP_ROTATION_INDEX:-}\" ]; then\n" - end_marker = " exit 1\n fi\n\n repositories_json=" - start = workflow.index(start_marker) - end = workflow.index(end_marker, start) + len(" exit 1\n fi\n") - return textwrap.dedent(workflow[start:end]) - - -def _fake_gh_script(*, get_ok: bool, get_value: str, patch_ok: bool, post_ok: bool) -> str: - """A stand-in `gh` executable simulating the repository-variable API. - - ``get_ok`` controls whether `gh api .../variables/NAME --jq .value` - exits zero at all -- a real "does the variable exist and is it - readable" outcome, kept distinct from what value it prints on success - (``get_value``), so tests can simulate a *failed* read (transient error - or a genuinely missing variable) separately from a *successful* read - of an empty/malformed value. ``patch_ok``/``post_ok`` control whether - the corresponding mutation exits zero, so tests can force the - PATCH-then-POST-create fallback or the full-failure wall-clock - fallback without a real GitHub API call. - """ - get_exit = "0" if get_ok else "1" - patch_exit = "0" if patch_ok else "1" - post_exit = "0" if post_ok else "1" - return textwrap.dedent( - f"""\ - #!/usr/bin/env bash - set -euo pipefail - if [ "$1" != "api" ]; then - echo "unsupported fake gh invocation: $*" >&2 - exit 2 - fi - shift - if [[ "$1" == *"/variables/"* ]] && [[ "$*" == *"-X PATCH"* || "$*" == *"PATCH"* ]]; then - exit {patch_exit} - fi - if [[ "$1" == "repos/"*"/actions/variables" ]]; then - exit {post_exit} - fi - if [[ "$1" == *"/variables/"* ]]; then - if [ "{get_exit}" = "0" ]; then - printf '%s' "{get_value}" - fi - exit {get_exit} - fi - echo "unsupported fake gh api path: $1" >&2 - exit 2 - """ - ) - - -def _run_rotation_default_snippet( - snippet: str, - tmp_path: Path, - *, - get_ok: bool = True, - get_value: str, - patch_ok: bool, - post_ok: bool, -) -> subprocess.CompletedProcess[str]: - """Execute the extracted default/validation block with a fake `gh` on PATH.""" - - fake_gh = tmp_path / "gh" - fake_gh.write_text( - _fake_gh_script(get_ok=get_ok, get_value=get_value, patch_ok=patch_ok, post_ok=post_ok), - encoding="utf-8", - ) - fake_gh.chmod(0o755) - script = snippet + '\nprintf "%s\\n" "$ORG_SWEEP_ROTATION_INDEX"\n' - env = dict(os.environ) - env.pop("ORG_SWEEP_ROTATION_INDEX", None) - env["GITHUB_REPOSITORY"] = "ContextualWisdomLab/.github" - env["PATH"] = f"{tmp_path}{os.pathsep}{env.get('PATH', '')}" - return subprocess.run( - ["bash", "-euo", "pipefail", "-c", script], env=env, capture_output=True, text=True - ) - - -def test_org_queue_sweep_rotation_index_uses_persistent_counter_when_available( - tmp_path: Path, -) -> None: - """The primary source increments a persistent counter by exactly one per - actual sweep execution — immune to how much wall-clock time a prior - slow (up to 60-minute, non-cancelling) run consumed, which a wall-clock - tick alone cannot guarantee (CodeRabbit review finding on #1223).""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - - result = _run_rotation_default_snippet( - 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 - - -def test_org_queue_sweep_rotation_index_counter_increment_forces_base_10( - tmp_path: Path, -) -> None: - """A manually-seeded leading-zero value ("08") must not be parsed as - octal, where it would error under set -e (Devin review finding on - #1223) — unprefixed bash arithmetic treats a leading zero as an octal - literal, and "08"/"09" are not valid octal digits.""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - - result = _run_rotation_default_snippet( - snippet, tmp_path, get_value="08", patch_ok=True, post_ok=True - ) - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "9" - - -def test_org_queue_sweep_rotation_index_creates_counter_on_first_run(tmp_path: Path) -> None: - """A failed read (variable does not exist yet) falls back to creating it.""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - - result = _run_rotation_default_snippet( - snippet, tmp_path, get_ok=False, get_value="", patch_ok=False, post_ok=True - ) - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "1" - - -def test_org_queue_sweep_rotation_index_falls_back_to_wall_clock(tmp_path: Path) -> None: - """If the persistent counter is entirely unavailable (both the read and - the create-on-first-run POST fail), degrade to a wall-clock tick rather - than failing the whole sweep over a fairness mechanism.""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - - result = _run_rotation_default_snippet( - snippet, tmp_path, get_ok=False, get_value="", patch_ok=False, post_ok=False - ) - 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 - expected_tick = int(time.time()) // 3600 - assert abs(computed_tick - expected_tick) <= 1 # tolerate a tick boundary race - assert "could not read/write" in result.stdout # a `::warning::` workflow command - - -def test_org_queue_sweep_rotation_index_transient_read_failure_does_not_reset_counter( - tmp_path: Path, -) -> None: - """A *failed* read must never be treated as "the counter is 0 and safe to - PATCH": that would silently reset an already-accumulated counter value - back down to 1, restarting the rotation sequence instead of degrading to - the wall-clock fallback (Devin review finding on #1223). Simulated here - as: the read fails, and the create-on-first-run POST also fails (as it - should when the variable genuinely already exists and this run simply - could not see it) -- landing on the wall-clock fallback rather than a - PATCH that would have clobbered the real value.""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - - result = _run_rotation_default_snippet( - snippet, tmp_path, get_ok=False, get_value="", patch_ok=True, post_ok=False - ) - assert result.returncode == 0, result.stderr - stdout_lines = result.stdout.strip().splitlines() - computed_tick = int(stdout_lines[-1]) - expected_tick = int(time.time()) // 3600 - 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" - - -def test_org_queue_sweep_rotation_index_successful_read_but_failed_patch_falls_back( - tmp_path: Path, -) -> None: - """A successful read of an existing value, followed by a failed PATCH, - must fall back to the wall-clock tick and log the value that could not - be written -- not silently drop the accumulated counter.""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - - result = _run_rotation_default_snippet( - snippet, tmp_path, get_ok=True, get_value="41", patch_ok=False, post_ok=False - ) - assert result.returncode == 0, result.stderr - stdout_lines = result.stdout.strip().splitlines() - computed_tick = int(stdout_lines[-1]) - expected_tick = int(time.time()) // 3600 - assert abs(computed_tick - expected_tick) <= 1 - assert "read ORG_SWEEP_ROTATION_COUNTER=41 but could not PATCH it" in result.stdout - - -def test_org_queue_sweep_rotation_index_override_is_preserved() -> None: - """An explicitly injected value (as tests do) is never overwritten.""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - script = snippet + '\nprintf "%s\\n" "$ORG_SWEEP_ROTATION_INDEX"\n' - - result = subprocess.run( - ["bash", "-euo", "pipefail", "-c", script], - env={**os.environ, "ORG_SWEEP_ROTATION_INDEX": "42"}, - capture_output=True, - text=True, - ) - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "42" - - -def test_org_queue_sweep_rotation_index_rejects_malformed_override() -> None: - """A malformed override still fails closed rather than reaching arithmetic.""" - - workflow = workflow_text("pr-review-merge-scheduler.yml") - snippet = _extract_org_sweep_rotation_default_snippet(workflow) - script = snippet + '\nprintf "%s\\n" "$ORG_SWEEP_ROTATION_INDEX"\n' - - result = subprocess.run( - ["bash", "-euo", "pipefail", "-c", script], - env={**os.environ, "ORG_SWEEP_ROTATION_INDEX": "not-a-number"}, - capture_output=True, - text=True, - ) - assert result.returncode != 0 - assert "ORG_SWEEP_ROTATION_INDEX must be a non-negative integer" in result.stdout - - -def test_org_queue_sweep_documents_rotation_leverage_and_validates_input() -> None: - """Record why rotation exists and keep the new input on the same fail-closed contract.""" - workflow = workflow_text("pr-review-merge-scheduler.yml") - - assert "ContextualWisdomLab/.github#1219" in workflow - assert ( - 'ORG_SWEEP_ROTATION_INDEX=$(( $(date -u +%s) / 3600 ))' - ) 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: ${{ 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 - - -def test_org_queue_sweep_manual_cadence_inputs_reach_the_sweep_job() -> None: - """Manual full-sweep cadence must override repository variables and defaults.""" - workflow = workflow_text("pr-review-merge-scheduler.yml") - - assert ( - "ORG_SWEEP_REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.review_dispatch_limit || inputs.review_dispatch_limit || " - "vars.ORG_SWEEP_REVIEW_DISPATCH_LIMIT || '1' }}" - ) in workflow - assert ( - "ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT: ${{ github.event.client_payload.stacked_review_dispatch_limit || " - "vars.ORG_SWEEP_STACKED_REVIEW_DISPATCH_LIMIT || '1' }}" - ) in workflow - assert ( - "STALE_OPENCODE_MINUTES: ${{ github.event.client_payload.stale_opencode_minutes || inputs.stale_opencode_minutes || " - "vars.STALE_OPENCODE_MINUTES || '90' }}" - ) in workflow - assert ( - "ORG_SWEEP_MAX_PRS: ${{ github.event.client_payload.max_prs || inputs.max_prs || vars.ORG_SWEEP_MAX_PRS || '1000' }}" - ) in workflow - assert ( - "ORG_SWEEP_TRIGGER_REVIEWS: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.trigger_reviews != false || inputs.trigger_reviews == true }}" - in workflow - ) - assert ( - "ORG_SWEEP_ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.enable_auto_merge != false || inputs.enable_auto_merge == true }}" - ) in workflow - assert ( - "ORG_SWEEP_MERGE_MODE: ${{ github.event.client_payload.merge_mode || inputs.merge_mode || 'direct_or_auto' }}" - in workflow - ) - assert ( - "ORG_SWEEP_UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'repository_dispatch' && github.event.client_payload.update_branches != false || inputs.update_branches == true }}" - in workflow - ) - assert 'if [ "$ORG_SWEEP_TRIGGER_REVIEWS" = "true" ]; then' in workflow - assert 'if [ "$ORG_SWEEP_ENABLE_AUTO_MERGE" = "true" ]; then' in workflow - assert '--merge-mode "$ORG_SWEEP_MERGE_MODE"' in workflow - assert 'if [ "$ORG_SWEEP_UPDATE_BRANCHES" = "true" ]; then' in workflow - - -def test_stacked_budget_is_not_declared_as_an_unused_workflow_call_input() -> None: - """Keep the stacked-only organization setting out of the reusable API.""" - workflow = workflow_text("pr-review-merge-scheduler.yml") - workflow_call = workflow.split(" workflow_call:", 1)[1].split( - " schedule:", 1 - )[0] - - assert "stacked_review_dispatch_limit" not in workflow_call - assert "inputs.stacked_review_dispatch_limit" not in workflow - - -def test_org_queue_sweep_active_run_aggregation_tolerates_error_payloads() -> None: - """An inaccessible Actions page must not add a secondary jq null error.""" - jq = shutil.which("jq") - if jq is None: - pytest.skip("jq is required for the executable workflow filter regression test") - - workflow = workflow_text("pr-review-merge-scheduler.yml") - aggregation_line = next( - line.strip() - for line in workflow.splitlines() - if "done | jq -sc" in line and "workflow_runs" in line - ) - jq_filter = shlex.split(aggregation_line)[4] - payload = ( - '{"workflow_runs":[]}\n{"message":"Resource not accessible by integration"}\n' - ) - - result = subprocess.run( - [jq, "-sc", jq_filter], - input=payload, - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - assert json.loads(result.stdout) == [] - - -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 - ``0 * * * *`` 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. - """ - 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 - - def test_fix_scheduler_cancels_superseded_cron_runs() -> None: """Cancel stale scheduled repair runs before they duplicate mutation work.""" workflow = workflow_text("pr-review-fix-scheduler.yml") assert "central-pr-review-fix-scheduler-" in workflow - assert "cancel-in-progress: true" in workflow + assert workflow_level_cancels_in_progress(workflow) def test_security_scan_fails_closed_when_dependency_review_is_unavailable() -> None: @@ -1740,33 +1560,14 @@ def test_secret_scan_push_limits_gitleaks_to_current_branch_history() -> None: workflow = workflow_text("secret-scan.yml") assert "CURRENT_SHA: ${{ github.sha }}" in workflow - assert 'log_opts="${BASE_SHA}..${HEAD_SHA}"' in workflow + assert "pull_request:" not in workflow.split("concurrency:", 1)[0] + assert "BASE_SHA:" not in workflow + assert "HEAD_SHA:" not in workflow assert 'log_opts="${CURRENT_SHA}"' in workflow assert '--log-opts="${log_opts}"' in workflow assert "unrelated remote refs are excluded" in workflow -def test_osv_pr_workflow_has_one_startup_safe_scan_args_block() -> None: - """Keep the standalone OSV workflow's resolver settings singular and safe.""" - workflow = workflow_text("osv-scanner-pr.yml") - concurrency_contract = workflow.split("permissions:", 1)[0] - - assert ( - "github.event_name == 'pull_request' && github.event.pull_request.base.repo.full_name" - in concurrency_contract - ) - assert ( - "github.event_name == 'pull_request' && github.event.pull_request.number" - in concurrency_contract - ) - assert workflow.count("scan-args: |-") == 1 - assert "--no-resolve" in workflow - assert ( - "--maven-registry=https://maven-central.storage-download.googleapis.com/maven2" - in workflow - ) - - def test_osv_scan_logs_and_retries_without_transitive_resolution_on_resolver_failure() -> ( None ): @@ -1919,19 +1720,6 @@ def test_pr_sarif_upload_rate_limits_do_not_mask_scanner_gates() -> None: assert warning_text in warning_step -def test_standalone_osv_scan_delegates_sarif_upload_to_central_gate() -> None: - """The supplemental OSV diff must not duplicate the central SARIF upload.""" - standalone = workflow_text("osv-scanner-pr.yml") - central = workflow_text("security-scan.yml") - - assert "upload-sarif: false" in standalone - assert "pinned upstream reusable workflow declares this permission" in standalone - assert "security-events: write" in standalone - assert "--fail-on-vuln=true" in central - assert "Print OSV findings being compared" in central - assert "Upload OSV SARIF to code scanning" in central - - def test_osv_findings_log_accepts_null_results_for_manifestless_repos( tmp_path: Path, ) -> None: @@ -2024,18 +1812,17 @@ def test_pr_scorecard_sarif_delegates_sast_and_vulnerability_posture_to_hard_gat 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) + workflow = workflow_text("security-scan.yml") - assert 'PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"}' in workflow - assert 'PR_GOVERNANCE_RULE_IDS = {"FuzzingID"}' in workflow - assert ( - "PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS" - in workflow - ) - assert "Delegated " in workflow - assert "CodeQL, OSV, Trivy, and dependency-review hard gates" in workflow - assert "default-branch governance tracking" in workflow + assert 'PR_HARD_GATE_RULE_IDS = {"SASTID", "VulnerabilitiesID"}' in workflow + assert 'PR_GOVERNANCE_RULE_IDS = {"FuzzingID"}' in workflow + assert ( + "PR_DELEGATED_RULE_IDS = PR_HARD_GATE_RULE_IDS | PR_GOVERNANCE_RULE_IDS" + in workflow + ) + assert "Delegated " in workflow + assert "CodeQL, OSV, Trivy, and dependency-review hard gates" in workflow + assert "default-branch governance tracking" in workflow default_branch_scorecard = workflow_text("scorecard-analysis.yml") @@ -2044,19 +1831,6 @@ def test_pr_scorecard_sarif_delegates_sast_and_vulnerability_posture_to_hard_gat assert "VulnerabilitiesID" not in default_branch_scorecard -def test_standalone_scorecard_delegates_code_scanning_upload_to_central_gate() -> None: - """The supplemental Scorecard run must not duplicate the central SARIF upload.""" - standalone = workflow_text("scorecard-pr.yml") - central = workflow_text("security-scan.yml") - - assert "security-events: write" not in standalone - assert "github/codeql-action/upload-sarif" not in standalone - assert "Preserve Scorecard PR SARIF evidence" in standalone - assert "actions/upload-artifact" in standalone - assert "Upload Scorecard SARIF to code scanning" in central - assert "category: scorecard" in central - - @pytest.mark.parametrize( ("workflow_name", "step_name"), ( diff --git a/tests/test_reusable_default_branch_scorecard_contract.py b/tests/test_reusable_default_branch_scorecard_contract.py new file mode 100644 index 0000000000..a8f3a76750 --- /dev/null +++ b/tests/test_reusable_default_branch_scorecard_contract.py @@ -0,0 +1,360 @@ +"""Contract tests for the reusable default-branch Scorecard workflow.""" + +from __future__ import annotations + +import ast +from collections import defaultdict +from pathlib import Path +from typing import TypeAlias + + +ContractScalar: TypeAlias = str | list[str] | None +ContractMapping: TypeAlias = dict[tuple[str, ...], ContractScalar] +BLOCK_SCALAR_MARKERS = frozenset({"|", "|-", ">", ">-"}) + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "scorecard-analysis.yml" + + +def _strip_inline_comment(line_text: str) -> str: + """Remove an unquoted YAML comment without truncating quoted hash characters.""" + single_quoted = False + double_quoted = False + escape_next = False + + for character_index, current_character in enumerate(line_text): + if escape_next: + escape_next = False + continue + if current_character == "\\" and double_quoted: + escape_next = True + continue + if current_character == "'" and not double_quoted: + single_quoted = not single_quoted + continue + if current_character == '"' and not single_quoted: + double_quoted = not double_quoted + continue + if ( + current_character == "#" + and not single_quoted + and not double_quoted + and ( + character_index == 0 + or line_text[character_index - 1].isspace() + ) + ): + return line_text[:character_index].rstrip() + + if single_quoted or double_quoted: + raise AssertionError(f"unterminated YAML quote: {line_text!r}") + return line_text.rstrip() + + +def _split_mapping_entry(entry_text: str) -> tuple[str, str]: + """Split one supported YAML mapping entry outside quotes and containers.""" + single_quoted = False + double_quoted = False + escape_next = False + brace_depth = 0 + bracket_depth = 0 + + for character_index, current_character in enumerate(entry_text): + if escape_next: + escape_next = False + continue + if current_character == "\\" and double_quoted: + escape_next = True + continue + if current_character == "'" and not double_quoted: + single_quoted = not single_quoted + continue + if current_character == '"' and not single_quoted: + double_quoted = not double_quoted + continue + if single_quoted or double_quoted: + continue + if current_character == "{": + brace_depth += 1 + elif current_character == "}": + if brace_depth <= 0: + raise AssertionError(f"unmatched YAML closing brace: {entry_text!r}") + brace_depth -= 1 + elif current_character == "[": + bracket_depth += 1 + elif current_character == "]": + if bracket_depth <= 0: + raise AssertionError( + f"unmatched YAML closing bracket: {entry_text!r}" + ) + bracket_depth -= 1 + elif ( + current_character == ":" + and brace_depth == 0 + and bracket_depth == 0 + ): + mapping_key = entry_text[:character_index].strip() + scalar_text = entry_text[character_index + 1 :].strip() + if not mapping_key: + raise AssertionError(f"empty YAML mapping key: {entry_text!r}") + return mapping_key, scalar_text + + if single_quoted or double_quoted or brace_depth or bracket_depth: + raise AssertionError(f"unterminated YAML mapping entry: {entry_text!r}") + raise AssertionError(f"unsupported YAML mapping entry: {entry_text!r}") + + +def _parse_scalar_value(scalar_text: str) -> ContractScalar: + """Parse only the scalar forms used by the governed workflow contract.""" + if not scalar_text: + return None + if scalar_text in BLOCK_SCALAR_MARKERS: + return scalar_text + if scalar_text[0] in {'"', "'", "["}: + parsed_value = ast.literal_eval(scalar_text) + if isinstance(parsed_value, list): + assert all( + isinstance(list_item, str) for list_item in parsed_value + ), "workflow contract accepts only inline string lists" + return parsed_value + assert isinstance(parsed_value, str), ( + "workflow contract accepts only string scalar literals" + ) + return parsed_value + return scalar_text + + +def _parse_workflow_contract(yaml_text: str) -> ContractMapping: + """Project supported YAML mappings into indentation-aware contract paths.""" + contract_mapping: ContractMapping = {} + path_stack: list[tuple[int, str]] = [] + sequence_counts: dict[tuple[str, ...], int] = defaultdict(int) + block_scalar_indent: int | None = None + + for raw_line in yaml_text.splitlines(): + if not raw_line.strip(): + continue + + leading_whitespace = raw_line[ + : len(raw_line) - len(raw_line.lstrip()) + ] + if "\t" in leading_whitespace: + raise AssertionError("tabs are not valid workflow indentation") + indent_width = len(leading_whitespace) + + if block_scalar_indent is not None: + if indent_width > block_scalar_indent: + continue + block_scalar_indent = None + + content_text = _strip_inline_comment(raw_line[indent_width:]) + if not content_text: + continue + + while path_stack and path_stack[-1][0] >= indent_width: + path_stack.pop() + parent_path = tuple( + path_component for _, path_component in path_stack + ) + + if content_text.startswith("- "): + item_index = sequence_counts[parent_path] + sequence_counts[parent_path] += 1 + item_component = f"[{item_index}]" + path_stack.append((indent_width, item_component)) + item_text = content_text[2:].strip() + if not item_text: + contract_mapping[parent_path + (item_component,)] = None + continue + + mapping_key, scalar_text = _split_mapping_entry(item_text) + item_path = parent_path + (item_component, mapping_key) + scalar_value = _parse_scalar_value(scalar_text) + contract_mapping[item_path] = scalar_value + if scalar_value is None: + path_stack.append((indent_width + 1, mapping_key)) + elif ( + isinstance(scalar_value, str) + and scalar_value in BLOCK_SCALAR_MARKERS + ): + block_scalar_indent = indent_width + continue + + mapping_key, scalar_text = _split_mapping_entry(content_text) + mapping_path = parent_path + (mapping_key,) + scalar_value = _parse_scalar_value(scalar_text) + contract_mapping[mapping_path] = scalar_value + if scalar_value is None: + path_stack.append((indent_width, mapping_key)) + elif ( + isinstance(scalar_value, str) + and scalar_value in BLOCK_SCALAR_MARKERS + ): + block_scalar_indent = indent_width + + return contract_mapping + + +def _load_workflow_contract() -> ContractMapping: + """Load the Scorecard workflow without undeclared test dependencies.""" + return _parse_workflow_contract(WORKFLOW_PATH.read_text(encoding="utf-8")) + + +def _mapping_contract( + workflow_contract: ContractMapping, + mapping_prefix: tuple[str, ...], +) -> dict[str, ContractScalar]: + """Return direct child values for one parsed mapping path.""" + return { + mapping_path[-1]: scalar_value + for mapping_path, scalar_value in workflow_contract.items() + if len(mapping_path) == len(mapping_prefix) + 1 + and mapping_path[: len(mapping_prefix)] == mapping_prefix + } + + +def _step_path_by_name( + workflow_contract: ContractMapping, + step_name: str, +) -> tuple[str, ...]: + """Return the sequence-item path for one named analysis step.""" + steps_prefix = ("jobs", "analysis", "steps") + for mapping_path, scalar_value in workflow_contract.items(): + if ( + len(mapping_path) == len(steps_prefix) + 2 + and mapping_path[: len(steps_prefix)] == steps_prefix + and mapping_path[-1] == "name" + and scalar_value == step_name + ): + return mapping_path[:-1] + raise AssertionError(f"missing Scorecard workflow step: {step_name}") + + +def test_contract_parser_ignores_comments_and_block_scalar_decoys() -> None: + """Comments and script literals must not satisfy workflow contracts.""" + fixture_text = """\ +# workflow_call: +name: "Parser # fixture" +on: + push: + branches: ["develop"] +jobs: + analysis: + steps: + - name: Script decoy + run: | + workflow_call: + uses: attacker/example@mutable + permissions: + security-events: write + - name: Checkout code + uses: actions/checkout@immutable # pinned release annotation + with: + persist-credentials: false +""" + fixture_contract = _parse_workflow_contract(fixture_text) + + assert fixture_contract[("name",)] == "Parser # fixture" + assert fixture_contract[("on", "push", "branches")] == ["develop"] + assert ("on", "workflow_call") not in fixture_contract + assert ( + "jobs", + "analysis", + "steps", + "[0]", + "uses", + ) not in fixture_contract + checkout_path = _step_path_by_name(fixture_contract, "Checkout code") + assert fixture_contract[checkout_path + ("uses",)] == ( + "actions/checkout@immutable" + ) + assert fixture_contract[ + checkout_path + ("with", "persist-credentials") + ] == "false" + + +def test_scorecard_analysis_is_reusable_without_losing_branch_history_triggers() -> None: + """Preserve push and scheduled SARIF refresh while enabling reuse.""" + workflow_contract = _load_workflow_contract() + + assert workflow_contract[("on", "workflow_call")] is None + assert workflow_contract[("on", "push", "branches")] == ["main"] + assert workflow_contract[("on", "schedule", "[0]", "cron")] == ( + "30 1 * * 6" + ) + + +def test_scorecard_analysis_never_discards_an_in_flight_scans_evidence() -> None: + """A newer queued push must never cancel an older scan mid-flight. + + .github#1768 (merged before this PR's own concurrency work landed) already + added a ref-scoped, cancel-in-progress: false group to this file for + exactly this reason: an in-flight Scorecard run's SARIF evidence for its + own commit must never be discarded, only serialized behind. This PR's own + earlier draft added a second, SHA-scoped, cancel-in-progress: true group to + the same file -- a real, independently-reasoned fix for a different + concern (the #1568-class stale-cancels-fresh race), but mutually exclusive + with #1768's group as a single `concurrency:` block: SHA-scoping gives + every distinct commit its own group, which would restore unbounded + concurrent scans across a push burst -- the exact problem #1768 closed, + and a direct regression of this org's standing Actions-queue-congestion + priority. Kept #1768's group as authoritative. + """ + workflow_contract = _load_workflow_contract() + + assert _mapping_contract(workflow_contract, ("concurrency",)) == { + "group": "scorecard-analysis-${{ github.ref }}", + "cancel-in-progress": "false", + } + + +def test_scorecard_analysis_keeps_authoritative_sarif_boundaries() -> None: + """Retain pinned analysis, credential hygiene, and SARIF upload.""" + workflow_contract = _load_workflow_contract() + + assert workflow_contract[("permissions",)] == "read-all" + assert _mapping_contract( + workflow_contract, + ("jobs", "analysis", "permissions"), + ) == { + "security-events": "write", + "id-token": "write", + "contents": "read", + "issues": "read", + "pull-requests": "read", + "checks": "read", + } + + checkout_path = _step_path_by_name(workflow_contract, "Checkout code") + assert workflow_contract[checkout_path + ("uses",)] == ( + "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" + ) + assert workflow_contract[ + checkout_path + ("with", "persist-credentials") + ] == "false" + + analysis_path = _step_path_by_name(workflow_contract, "Run analysis") + assert workflow_contract[analysis_path + ("uses",)] == ( + "ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a" + ) + assert _mapping_contract( + workflow_contract, + analysis_path + ("with",), + ) == { + "results_file": "results.sarif", + "results_format": "sarif", + "publish_results": "false", + } + + upload_path = _step_path_by_name( + workflow_contract, + "Upload to code scanning", + ) + assert workflow_contract[upload_path + ("continue-on-error",)] == "true" + assert workflow_contract[upload_path + ("uses",)] == ( + "github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938" + ) + assert _mapping_contract( + workflow_contract, + upload_path + ("with",), + ) == {"sarif_file": "results.sarif"} diff --git a/tests/test_review_admission_controller.py b/tests/test_review_admission_controller.py new file mode 100644 index 0000000000..ce83f13918 --- /dev/null +++ b/tests/test_review_admission_controller.py @@ -0,0 +1,440 @@ +import json +import os +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from scripts.ci import review_admission_controller as controller +from scripts.ci.review_admission_controller import ( + ADMISSION_PERMISSIONS, + WORKER_BOUNDARIES, + AdmissionRequest, + ControllerState, + DispatchLease, + RequestRecord, + WorkerBoundary, + complete_dispatch, + load_state_file, + plan_dispatches, + require_publishable, + update_state_file, +) + +HEAD_1 = "1" * 40 +HEAD_2 = "2" * 40 +HEAD_3 = "3" * 40 + + +def request(component: str, head: str = HEAD_2, sequence: int = 2) -> AdmissionRequest: + return AdmissionRequest.create( + repository="ContextualWisdomLab/example", + pull_request=7, + head_sha=head, + component=component, + sequence=sequence, + ) + + +def test_controller_is_idempotent_bounded_and_rejects_stale_or_out_of_order() -> None: + state = ControllerState.empty() + stale = request("opencode", HEAD_1, 1) + current = request("opencode") + duplicate = request("opencode") + noema = request("noema") + strix = request("strix") + + plan = plan_dispatches( + state, + [stale, current, duplicate, noema, strix], + live_heads={(current.repository, current.pull_request): HEAD_2}, + dispatch_budget=2, + ) + + assert [item.request.component for item in plan.dispatches] == ["opencode", "noema"] + assert plan.rejections[stale.identity] == "stale_head" + assert plan.rejections[duplicate.identity] == "duplicate" + assert plan.state.records[current.identity].status == "dispatched" + assert plan.state.records[strix.identity].status == "queued" + assert ControllerState.from_json(plan.state.to_json()) == plan.state + + completed_state = complete_dispatch( + complete_dispatch(plan.state, plan.dispatches[0], live_head=HEAD_2), + plan.dispatches[1], + live_head=HEAD_2, + ) + repeated = plan_dispatches( + completed_state, + [current, noema, strix], + live_heads={(current.repository, current.pull_request): HEAD_2}, + dispatch_budget=2, + ) + assert [item.request.component for item in repeated.dispatches] == ["strix"] + assert repeated.rejections[current.identity] == "idempotent" + assert repeated.rejections[noema.identity] == "idempotent" + + delayed = plan_dispatches( + repeated.state, + [request("opencode", HEAD_3, 1)], + live_heads={(current.repository, current.pull_request): HEAD_2}, + dispatch_budget=1, + ) + assert delayed.rejections[request("opencode", HEAD_3, 1).identity] == "out_of_order" + + +def test_worker_boundaries_remain_separate_and_publish_requires_live_head_cas() -> None: + assert ADMISSION_PERMISSIONS == ("contents: read", "pull-requests: read") + assert set(WORKER_BOUNDARIES) == {"opencode", "noema", "strix"} + assert len({boundary.credential for boundary in WORKER_BOUNDARIES.values()}) == 3 + assert ( + len({boundary.concurrency_namespace for boundary in WORKER_BOUNDARIES.values()}) + == 3 + ) + assert all( + "pull-requests: read" in boundary.permissions + for boundary in WORKER_BOUNDARIES.values() + ) + assert all(boundary.cancel_in_progress for boundary in WORKER_BOUNDARIES.values()) + assert WORKER_BOUNDARIES["strix"].concurrency_group(request("strix")) == ( + "strix-security-scan-ContextualWisdomLab/example-7" + ) + + planned = plan_dispatches( + ControllerState.empty(), + [request("strix")], + live_heads={("ContextualWisdomLab/example", 7): HEAD_2}, + dispatch_budget=1, + ) + item = planned.dispatches[0] + require_publishable(item, live_head=HEAD_2) + completed = complete_dispatch( + planned.state, + item, + live_head=HEAD_2, + ) + assert completed.records[item.request.identity].status == "complete" + + try: + require_publishable(item, live_head=HEAD_1) + except ValueError as exc: + assert str(exc) == "live head changed before publication" + else: # pragma: no cover + raise AssertionError("stale publication was accepted") + + forged = DispatchLease( + item.request, + WorkerBoundary("wrong", ("contents: write",), "shared"), + ) + with pytest.raises(ValueError, match="worker boundary"): + require_publishable(forged, live_head=HEAD_2) + + +def test_state_file_is_atomic_recovers_and_serializes_concurrent_writers(tmp_path) -> None: + state_path = tmp_path / "controller.json" + barrier = threading.Barrier(8) + + def writer(sequence: int) -> None: + barrier.wait() + + def add(state: ControllerState) -> ControllerState: + item = AdmissionRequest.create( + repository=f"ContextualWisdomLab/repo-{sequence}", + pull_request=sequence, + head_sha=f"{sequence:x}" * 40, + component="opencode", + sequence=1, + ) + records = dict(state.records) + records[item.identity] = RequestRecord(item, "queued") + latest = dict(state.latest_sequences) + latest[item.stream] = 1 + return ControllerState(records, latest) + + update_state_file(state_path, add) + + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(writer, range(1, 9))) + + persisted = load_state_file(state_path) + assert len(persisted.records) == 8 + assert state_path.stat().st_mode & 0o777 == 0o600 + state_path.write_text("{truncated", encoding="utf-8") + assert load_state_file(state_path) == persisted + state_path.unlink() + assert load_state_file(state_path) == persisted + + +def test_state_rejects_unsafe_paths_shapes_and_secret_fields(tmp_path) -> None: + target = tmp_path / "target.json" + target.write_text(ControllerState.empty().to_json(), encoding="utf-8") + link = tmp_path / "state.json" + link.symlink_to(target) + with pytest.raises(ValueError, match="symlink"): + load_state_file(link) + with pytest.raises(ValueError, match="symlink"): + update_state_file(link, lambda state: state) + + with pytest.raises(ValueError, match="outside ContextualWisdomLab"): + AdmissionRequest.create( + repository="ContextualWisdomLab/../../secrets", + pull_request=1, + head_sha=HEAD_1, + component="opencode", + sequence=1, + ) + with pytest.raises(ValueError, match="unknown review component"): + request("../../worker") + with pytest.raises(TypeError, match="integer"): + AdmissionRequest.create( + repository="ContextualWisdomLab/example", + pull_request=True, + head_sha=HEAD_1, + component="opencode", + sequence=1, + ) + + payload = json.loads(ControllerState.empty().to_json()) + payload["credential"] = "should-never-persist" + with pytest.raises(ValueError, match="unknown fields"): + ControllerState.from_json(json.dumps(payload)) + + poisoned = json.loads(ControllerState.empty().to_json()) + poisoned["latest_sequences"]["ContextualWisdomLab/example#7:opencode"] = 999 + with pytest.raises(ValueError, match="unknown streams"): + ControllerState.from_json(json.dumps(poisoned)) + + +def test_budget_counts_active_leases_and_stale_heads_cannot_poison_sequence() -> None: + current = request("opencode", HEAD_2, 2) + first = plan_dispatches( + ControllerState.empty(), + [current], + live_heads={(current.repository, current.pull_request): HEAD_2}, + dispatch_budget=1, + ) + noema = request("noema", HEAD_2, 2) + saturated = plan_dispatches( + first.state, + [noema], + live_heads={(current.repository, current.pull_request): HEAD_2}, + dispatch_budget=1, + ) + assert saturated.dispatches == () + assert saturated.state.records[noema.identity].status == "queued" + + stale = request("strix", HEAD_3, 99) + stale_plan = plan_dispatches( + ControllerState.empty(), + [stale], + live_heads={(stale.repository, stale.pull_request): HEAD_2}, + dispatch_budget=1, + ) + assert stale.stream not in stale_plan.state.latest_sequences + assert ControllerState.from_json(stale_plan.state.to_json()) == stale_plan.state + valid = request("strix", HEAD_2, 1) + recovered = plan_dispatches( + stale_plan.state, + [valid], + live_heads={(valid.repository, valid.pull_request): HEAD_2}, + dispatch_budget=1, + ) + assert recovered.dispatches[0].request == valid + + retry_state = ControllerState( + {valid.identity: RequestRecord(valid, "stale")}, + {}, + ) + retried = plan_dispatches( + retry_state, + [request("strix", HEAD_2, 2)], + live_heads={(valid.repository, valid.pull_request): HEAD_2}, + dispatch_budget=1, + ) + assert retried.dispatches[0].request.sequence == 2 + + +@pytest.mark.parametrize( + ("changes", "error", "message"), + ( + ({"sequence": True}, TypeError, "sequence must be an integer"), + ({"pull_request": 0}, ValueError, "pull request must be positive"), + ({"head_sha": "short"}, ValueError, "head must be a full Git SHA"), + ({"sequence": 0}, ValueError, "sequence must be positive"), + ), +) +def test_request_rejects_each_invalid_scalar(changes, error, message) -> None: + values = { + "repository": "ContextualWisdomLab/example", + "pull_request": 7, + "head_sha": HEAD_1, + "component": "opencode", + "sequence": 1, + } + values.update(changes) + with pytest.raises(error, match=message): + AdmissionRequest.create(**values) + + +@pytest.mark.parametrize( + ("payload", "error", "message"), + ( + ([], TypeError, "must be an object"), + ({"records": []}, TypeError, "invalid collections"), + ( + {"records": {"bad": {"request": {}, "extra": 1}}, "latest_sequences": {}}, + ValueError, + "invalid durable admission record", + ), + ( + { + "records": { + "bad": { + "request": { + "repository": "ContextualWisdomLab/example", + "pull_request": 7, + }, + "status": "queued", + } + }, + "latest_sequences": {}, + }, + ValueError, + "invalid durable admission request", + ), + ), +) +def test_state_json_rejects_malformed_top_level_shapes(payload, error, message) -> None: + with pytest.raises(error, match=message): + ControllerState.from_json(json.dumps(payload)) + + +def test_state_json_rejects_non_string_record_identity(monkeypatch) -> None: + monkeypatch.setattr( + controller.json, + "loads", + lambda _serialized: {"records": {1: {}}, "latest_sequences": {}}, + ) + with pytest.raises(TypeError, match="invalid shape"): + ControllerState.from_json("ignored") + + +def test_state_json_rejects_identity_status_sequence_and_regression() -> None: + item = request("opencode", HEAD_1, 1) + + def encoded(identity=item.identity, status="queued", latest=None, record=item): + return json.dumps( + { + "records": { + identity: { + "request": { + "repository": record.repository, + "pull_request": record.pull_request, + "head_sha": record.head_sha, + "component": record.component, + "sequence": record.sequence, + }, + "status": status, + } + }, + "latest_sequences": latest + if latest is not None + else {item.stream: 1}, + } + ) + + with pytest.raises(ValueError, match="invalid durable admission record"): + ControllerState.from_json(encoded(identity="wrong")) + with pytest.raises(ValueError, match="invalid durable admission record"): + ControllerState.from_json(encoded(status="unknown")) + with pytest.raises(ValueError, match="invalid durable admission sequence"): + ControllerState.from_json(encoded(latest={item.stream: True})) + regressed = request("opencode", HEAD_1, 2) + with pytest.raises(ValueError, match="sequence regressed"): + ControllerState.from_json( + encoded( + identity=regressed.identity, + latest={regressed.stream: 1}, + record=regressed, + ) + ) + with pytest.raises(ValueError, match="sequence is inconsistent"): + ControllerState.from_json(encoded(latest={item.stream: 2})) + + +def test_state_file_rejects_corruption_symlinks_and_nonregular_paths(tmp_path) -> None: + corrupt = tmp_path / "corrupt.json" + corrupt.write_text("{", encoding="utf-8") + with pytest.raises(ValueError, match="corrupt and has no backup"): + load_state_file(corrupt) + + invalid_utf8 = tmp_path / "invalid.json" + invalid_utf8.write_bytes(b"\xff") + with pytest.raises(ValueError, match="not UTF-8"): + controller._read_state(invalid_utf8) + + with pytest.raises(ValueError, match="not a regular file"): + controller._open_regular_nofollow(tmp_path, os.O_RDONLY) + + state_path = tmp_path / "state.json" + backup = tmp_path / "state.json.bak" + backup.symlink_to(corrupt) + with pytest.raises(ValueError, match="backup must not be a symlink"): + load_state_file(state_path) + + atomic_link = tmp_path / "atomic.json" + atomic_link.symlink_to(corrupt) + with pytest.raises(ValueError, match="state path must not be a symlink"): + controller._atomic_write(atomic_link, "{}") + + lock_link = tmp_path / "locked.json.lock" + lock_link.symlink_to(corrupt) + with pytest.raises(ValueError, match="lock must not be a symlink"): + update_state_file(tmp_path / "locked.json", lambda state: state) + + +def test_update_and_dispatch_reject_invalid_transitions(tmp_path) -> None: + with pytest.raises(TypeError, match="must return ControllerState"): + update_state_file(tmp_path / "state.json", lambda state: object()) + with pytest.raises(ValueError, match="budget must not be negative"): + plan_dispatches(ControllerState.empty(), [], live_heads={}, dispatch_budget=-1) + + item = request("opencode", HEAD_2, 2) + lease = DispatchLease(item, WORKER_BOUNDARIES["opencode"]) + with pytest.raises(ValueError, match="active dispatch lease"): + complete_dispatch(ControllerState.empty(), lease, live_head=HEAD_2) + + +def test_new_head_stales_queued_predecessor_and_dispatch_rechecks_live_head() -> None: + old = request("opencode", HEAD_1, 1) + current = request("opencode", HEAD_2, 2) + state = ControllerState( + {old.identity: RequestRecord(old, "queued")}, + {old.stream: 1}, + ) + plan = plan_dispatches( + state, + [current], + live_heads={(current.repository, current.pull_request): HEAD_2}, + dispatch_budget=1, + ) + assert plan.state.records[old.identity].status == "stale" + + class MovingHeads(dict): + reads = 0 + + def get(self, key, default=None): + self.reads += 1 + return HEAD_2 if self.reads == 1 else HEAD_3 + + moved = plan_dispatches( + ControllerState.empty(), + [current], + live_heads=MovingHeads(), + dispatch_budget=1, + ) + assert moved.dispatches == () + assert moved.rejections[current.identity] == "stale_head" + + +def test_controller_self_test_executes_public_smoke_contract() -> None: + controller.self_test() diff --git a/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py b/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py new file mode 100644 index 0000000000..ba0b2598a9 --- /dev/null +++ b/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py @@ -0,0 +1,67 @@ +"""Contract tests for the remaining central caller/dispatch runner images. + +`docs/product-technical-gap-baseline.md`'s starved-`ubuntu-latest` entry +deliberately scoped its fix to the one file with direct, confirmed live +evidence at the time, naming `pr-review-autofix.yml`, +`pr-review-fix-scheduler.yml`, `hourly-review-repair.yml`, `codeql-pr.yml`, +and `codeql-scan-dispatch.yml` as residual occurrences to revisit "if queuing +symptoms recur on them specifically." They did: all five, plus +`python-security.yml` (found independently while investigating the same +symptom), were still requesting the unpinned image. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +PR_REVIEW_AUTOFIX = Path(".github/workflows/pr-review-autofix.yml") +PR_REVIEW_FIX_SCHEDULER = Path(".github/workflows/pr-review-fix-scheduler.yml") +HOURLY_REVIEW_REPAIR = Path(".github/workflows/hourly-review-repair.yml") +CODEQL_PR = Path(".github/workflows/codeql-pr.yml") +CODEQL_SCAN_DISPATCH = Path(".github/workflows/codeql-scan-dispatch.yml") +PYTHON_SECURITY = Path(".github/workflows/python-security.yml") + + +class SchedulerAndCodeqlDispatchRunnerImageContract(unittest.TestCase): + """Keep these central callers/dispatchers off the observed starved image.""" + + def assert_explicit_supported_image(self, path: Path) -> None: + """Require every job runner declaration to pin Ubuntu 24.04.""" + workflow = path.read_text(encoding="utf-8") + self.assertNotIn("runs-on: ubuntu-latest", workflow, path) + self.assertIn("runs-on: ubuntu-24.04", workflow, path) + + def test_pr_review_autofix_uses_explicit_supported_image(self) -> None: + """Require the PR Review Autofix job to use explicit Ubuntu 24.04.""" + self.assert_explicit_supported_image(PR_REVIEW_AUTOFIX) + + def test_pr_review_fix_scheduler_uses_explicit_supported_image(self) -> None: + """Require the reusable fix-scheduler dispatch job to pin Ubuntu 24.04.""" + self.assert_explicit_supported_image(PR_REVIEW_FIX_SCHEDULER) + + def test_hourly_review_repair_uses_explicit_supported_image(self) -> None: + """Require the hourly review-repair resolve-target job to pin Ubuntu 24.04.""" + self.assert_explicit_supported_image(HOURLY_REVIEW_REPAIR) + + def test_codeql_pr_uses_explicit_supported_image(self) -> None: + """Require detect-languages, analyze-head, and the coordinator to pin Ubuntu 24.04.""" + workflow = CODEQL_PR.read_text(encoding="utf-8") + self.assertNotIn("runs-on: ubuntu-latest", workflow) + self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 3) + + def test_codeql_scan_dispatch_uses_explicit_supported_image(self) -> None: + """Require both CodeQL Scan Dispatch jobs to pin Ubuntu 24.04.""" + workflow = CODEQL_SCAN_DISPATCH.read_text(encoding="utf-8") + self.assertNotIn("runs-on: ubuntu-latest", workflow) + self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 2) + + def test_python_security_uses_explicit_supported_image(self) -> None: + """Require all three Python Security jobs to pin Ubuntu 24.04.""" + workflow = PYTHON_SECURITY.read_text(encoding="utf-8") + self.assertNotIn("runs-on: ubuntu-latest", workflow) + self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scheduler_opencode_followup_defer_contract.py b/tests/test_scheduler_opencode_followup_defer_contract.py new file mode 100644 index 0000000000..c6564e6d6d --- /dev/null +++ b/tests/test_scheduler_opencode_followup_defer_contract.py @@ -0,0 +1,80 @@ +"""Cross-file contract for OpenCode follow-up rate-limit deferral.""" + +from __future__ import annotations + +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +DISPATCH_WORKFLOW_PATH = ( + REPOSITORY_ROOT / ".github" / "workflows" / "opencode-review-dispatch.yml" +) +SCHEDULER_FACADE_PATH = ( + REPOSITORY_ROOT / "scripts" / "ci" / "pr_review_merge_scheduler.py" +) + + +def _merge_scheduler_step(workflow_source: str) -> str: + """Return the OpenCode post-approval merge-scheduler step.""" + + marker = " - name: Run merge scheduler after approval\n" + step_start = workflow_source.index(marker) + try: + step_end = workflow_source.index("\n - name:", step_start + len(marker)) + except ValueError: + step_end = len(workflow_source) + return workflow_source[step_start:step_end] + + +def test_facade_signature_matches_the_live_opencode_followup_caller() -> None: + """Fail when caller arguments drift away from the scoped defer predicate.""" + + workflow_source = DISPATCH_WORKFLOW_PATH.read_text(encoding="utf-8") + scheduler_step = _merge_scheduler_step(workflow_source) + facade_source = SCHEDULER_FACADE_PATH.read_text(encoding="utf-8") + + assert workflow_source.startswith("name: OpenCode Review Dispatch\n") + for required_argument in ( + '--max-prs 1', + '--review-dispatch-limit 0', + '--merge-mode direct_or_auto', + '--pr-number "$PR_NUMBER"', + '--no-trigger-reviews', + '--enable-auto-merge', + '--no-update-branches', + ): + assert required_argument in scheduler_step + + assert 'GITHUB_WORKFLOW", "") == "OpenCode Review Dispatch"' in facade_source + assert '_argument_value(argument_values, "--max-prs") == "1"' in facade_source + assert ( + '_argument_value(argument_values, "--review-dispatch-limit") == "0"' + in facade_source + ) + assert '== "direct_or_auto"' in facade_source + + +def test_followup_documents_the_authoritative_retry_owner() -> None: + """Keep a bounded scheduler path after this best-effort caller defers.""" + + workflow_source = DISPATCH_WORKFLOW_PATH.read_text(encoding="utf-8") + scheduler_step = _merge_scheduler_step(workflow_source) + facade_source = SCHEDULER_FACADE_PATH.read_text(encoding="utf-8") + + assert "scheduled scheduler paths remain authoritative" in scheduler_step + assert "review-event and scheduled scheduler paths remain authoritative" in scheduler_step + assert "Required PR Review Merge Scheduler heartbeat" in facade_source + + +def test_rate_limit_defer_stops_the_existing_outer_retry_loop() -> None: + """Pair caller non-zero retry behavior with facade success-on-defer behavior.""" + + workflow_source = DISPATCH_WORKFLOW_PATH.read_text(encoding="utf-8") + scheduler_step = _merge_scheduler_step(workflow_source) + facade_source = SCHEDULER_FACADE_PATH.read_text(encoding="utf-8") + + assert "for attempt in 1 2 3; do" in scheduler_step + assert 'sleep "$((attempt * 5))"' in scheduler_step + assert "and _is_opencode_post_approval_followup(argument_values)" in facade_source + assert "return 0" in facade_source + assert "scheduler_outcome=deferred_rate_limit" in facade_source diff --git a/tests/test_scheduler_rate_limit_fail_fast_entrypoint.py b/tests/test_scheduler_rate_limit_fail_fast_entrypoint.py new file mode 100644 index 0000000000..a0995aebaa --- /dev/null +++ b/tests/test_scheduler_rate_limit_fail_fast_entrypoint.py @@ -0,0 +1,446 @@ +"""Contracts for fail-fast GitHub primary rate-limit handling.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.ci import pr_review_merge_scheduler as scheduler_facade +from scripts.ci import pr_review_merge_scheduler_core as scheduler_core + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +FACADE_PATH = ( + REPOSITORY_ROOT / "scripts" / "ci" / "pr_review_merge_scheduler.py" +) +CORE_PATH = ( + REPOSITORY_ROOT + / "scripts" + / "ci" + / "pr_review_merge_scheduler_core.py" +) + + +def _post_approval_arguments() -> list[str]: + """Return the exact OpenCode post-publication scheduler signature.""" + + return [ + "--repo", + "ContextualWisdomLab/example-service", + "--base-branch", + "main", + "--max-prs", + "1", + "--project-flow", + "github-flow", + "--review-workflow", + "Required OpenCode Review", + "--security-workflow", + "Strix Security Scan", + "--review-dispatch-limit", + "0", + "--no-trigger-reviews", + "--enable-auto-merge", + "--merge-mode", + "direct_or_auto", + "--no-update-branches", + "--pr-number", + "42", + ] + + +@pytest.fixture(autouse=True) +def restore_scheduler_api_helpers(): + """Restore core API helpers after each installer-focused regression test.""" + + original_graphql = scheduler_core.gh_graphql + original_rest = scheduler_core.gh_api_json + yield + scheduler_core.gh_graphql = original_graphql + scheduler_core.gh_api_json = original_rest + + +def test_graphql_rate_limit_fails_after_one_request_without_sleep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not hold a runner once the shared GraphQL bucket is exhausted.""" + + calls: list[list[str]] = [] + sleeps: list[int] = [] + + def exhausted_read( + command: list[str], *, stdin: str | None = None + ) -> str: + calls.append(command) + assert stdin == "query { viewer { login } }" + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "run_github_read", exhausted_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + with pytest.raises(RuntimeError, match="API rate limit exceeded"): + scheduler_core.gh_graphql("query { viewer { login } }") + + assert len(calls) == 1 + assert sleeps == [] + assert ["gh", "api", "rate_limit"] not in calls + + +def test_rest_rate_limit_fails_after_one_request_without_sleep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not query reset metadata or sleep after a REST bucket exhaustion.""" + + calls: list[list[str]] = [] + sleeps: list[int] = [] + + def exhausted_read( + command: list[str], *, stdin: str | None = None + ) -> str: + calls.append(command) + assert stdin is None + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "run_github_read", exhausted_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + with pytest.raises(RuntimeError, match="API rate limit exceeded"): + scheduler_core.gh_api_json("repos/example/project") + + assert calls == [["gh", "api", "repos/example/project"]] + assert sleeps == [] + + +def test_transient_transport_error_keeps_one_short_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve bounded recovery for a passing GitHub transport failure.""" + + responses: list[object] = [ + RuntimeError("temporary server error"), + '{"ok": true}', + ] + sleeps: list[int] = [] + + def transient_read( + command: list[str], *, stdin: str | None = None + ) -> str: + assert command == ["gh", "api", "repos/example/project"] + assert stdin is None + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + monkeypatch.setattr(scheduler_core, "run_github_read", transient_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + assert scheduler_core.gh_api_json("repos/example/project") == { + "ok": True + } + assert sleeps == [1] + assert responses == [] + + +def test_graphql_transient_transport_error_keeps_one_short_retry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve bounded recovery for a passing GraphQL transport failure.""" + + responses: list[object] = [ + RuntimeError("HTTP 502: bad gateway"), + '{"data": {"ok": true}}', + ] + sleeps: list[int] = [] + + def transient_read( + command: list[str], *, stdin: str | None = None + ) -> str: + assert stdin == "query { viewer { login } }" + response = responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + monkeypatch.setattr(scheduler_core, "run_github_read", transient_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + assert scheduler_core.gh_graphql("query { viewer { login } }") == { + "data": {"ok": True} + } + assert sleeps == [1] + assert responses == [] + + +def test_graphql_forwards_extra_fields_with_correct_flags( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Forward string and integer GraphQL variables with their matching gh flags.""" + + calls: list[list[str]] = [] + + def capturing_read( + command: list[str], *, stdin: str | None = None + ) -> str: + calls.append(command) + return '{"data": {}}' + + monkeypatch.setattr(scheduler_core, "run_github_read", capturing_read) + scheduler_facade.install_fail_fast_rate_limit_policy() + + scheduler_core.gh_graphql( + "query($repo: String!, $number: Int!) { }", + repo="ContextualWisdomLab/example-service", + number=42, + ) + + assert calls == [ + [ + "gh", + "api", + "graphql", + "-F", + "query=@-", + "-f", + "repo=ContextualWisdomLab/example-service", + "-F", + "number=42", + ] + ] + + +def test_non_transient_graphql_and_rest_errors_raise_on_first_attempt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Never retry a GitHub failure that is neither rate-limited nor transient.""" + + calls: list[list[str]] = [] + sleeps: list[int] = [] + + def failing_read( + command: list[str], *, stdin: str | None = None + ) -> str: + calls.append(command) + raise RuntimeError("HTTP 422: schema validation failed") + + monkeypatch.setattr(scheduler_core, "run_github_read", failing_read) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + scheduler_facade.install_fail_fast_rate_limit_policy() + + with pytest.raises(RuntimeError, match="schema validation failed"): + scheduler_core.gh_graphql("query { viewer { login } }") + with pytest.raises(RuntimeError, match="schema validation failed"): + scheduler_core.gh_api_json("repos/example/project") + + assert len(calls) == 2 + assert sleeps == [] + + +def test_opencode_followup_defer_without_step_summary_target( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip writing a job summary when no GITHUB_STEP_SUMMARY path is set.""" + + argument_values = _post_approval_arguments() + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + monkeypatch.delenv("GITHUB_STEP_SUMMARY", raising=False) + + assert scheduler_facade.run_cli(argument_values) == 0 + + +def test_post_approval_signature_requires_a_value_after_each_flag( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A tracked option with no following value never satisfies the signature.""" + + argument_values = [*_post_approval_arguments()[:16], "--merge-mode"] + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + + assert scheduler_facade.run_cli(argument_values) == 1 + + +def test_facade_dunder_attribute_writes_use_the_real_module_protocol() -> None: + """Dunder names are never forwarded to the core module, even for writes.""" + + original_doc = scheduler_facade.__doc__ + try: + setattr(scheduler_facade, "__doc__", "temporary") + assert scheduler_facade.__dict__["__doc__"] == "temporary" + delattr(scheduler_facade, "__doc__") + assert "__doc__" not in scheduler_facade.__dict__ + finally: + setattr(scheduler_facade, "__doc__", original_doc) + + assert scheduler_facade.__doc__ == original_doc + + +def test_dir_merges_facade_and_core_module_names() -> None: + """dir() on the facade module exposes both its own and the core's names.""" + + names = dir(scheduler_facade) + + assert "run_cli" in names + assert "gh_graphql" in names + + +def test_opencode_followup_accepts_typed_rate_limit_defer_without_outer_retry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Stop the OpenCode caller's 5, 10, and 15 second retry sleeps.""" + + argument_values = _post_approval_arguments() + summary_path = tmp_path / "step-summary.md" + sleeps: list[int] = [] + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setattr(scheduler_core.time, "sleep", sleeps.append) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary_path)) + + assert scheduler_facade.run_cli(argument_values) == 0 + assert sleeps == [] + summary = summary_path.read_text(encoding="utf-8") + assert "outcome: `deferred_rate_limit`" in summary + assert "retry owner: Required PR Review Merge Scheduler heartbeat" in summary + assert "runner-held sleep: 0 seconds" in summary + + +def test_org_sweep_rate_limit_remains_nonzero_and_stops_rotation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Preserve #1245's organization-rotation stop signal.""" + + argument_values = [ + "--repo", + "ContextualWisdomLab/example-service", + "--base-branch", + "main", + "--max-prs", + "8", + "--review-dispatch-limit", + "3", + ] + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "Required PR Review Merge Scheduler") + + assert scheduler_facade.run_cli(argument_values) == 1 + + +def test_caller_name_alone_cannot_relabel_org_scan_as_accepted_defer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Require the exact post-approval argument signature as well as workflow.""" + + argument_values = ["--repo", "ContextualWisdomLab/example-service"] + + def deferred_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("API rate limit exceeded for installation") + + monkeypatch.setattr(scheduler_core, "main", deferred_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + + assert scheduler_facade.run_cli(argument_values) == 1 + + +def test_cli_keeps_non_rate_limit_failure_blocking( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Do not relabel an unrelated scheduler defect as accepted deferral.""" + + argument_values = _post_approval_arguments() + + def failing_main(received_arguments: list[str]) -> int: + assert received_arguments == argument_values + raise RuntimeError("invalid repository payload") + + monkeypatch.setattr(scheduler_core, "main", failing_main) + monkeypatch.setenv("GITHUB_WORKFLOW", "OpenCode Review Dispatch") + + assert scheduler_facade.run_cli(argument_values) == 1 + + +def test_legacy_monkeypatches_are_forwarded_to_the_core_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Keep existing tests and callers on the stable import path.""" + + sentinel = object() + monkeypatch.setattr( + scheduler_facade, + "DEFAULT_STALE_OPENCODE_MINUTES", + sentinel, + ) + + assert scheduler_core.DEFAULT_STALE_OPENCODE_MINUTES is sentinel + assert scheduler_facade.DEFAULT_STALE_OPENCODE_MINUTES is sentinel + + +def test_wildcard_import_preserves_the_original_public_scheduler_api() -> None: + """Export delegated public APIs through the stable facade path.""" + + imported_namespace: dict[str, object] = {} + exec( + "from scripts.ci.pr_review_merge_scheduler import *", + imported_namespace, + ) + + assert imported_namespace["main"] is scheduler_core.main + assert imported_namespace["gh_graphql"] is scheduler_core.gh_graphql + assert imported_namespace["gh_api_json"] is scheduler_core.gh_api_json + assert "_scheduler_core" not in imported_namespace + assert "main" in scheduler_facade.__all__ + + +def test_core_owns_the_existing_dispatch_contract_markers() -> None: + """Keep static dispatch evidence on the implementation, not only facade.""" + + core_source = CORE_PATH.read_text(encoding="utf-8") + for marker in ( + 'f"repos/{dispatch_repo}/dispatches"', + '"event_type": "opencode-review"', + '"event_type": "strix-scan"', + ): + assert marker in core_source + + +def test_facade_installs_no_reset_lookup_on_the_production_entrypoint() -> None: + """Guard against reintroducing rate-limit polling into the stable CLI.""" + + facade_source = FACADE_PATH.read_text(encoding="utf-8") + + assert "install_fail_fast_rate_limit_policy()" in facade_source + assert "rate_limit_retry_delay_seconds(" not in facade_source + assert '["gh", "api", "rate_limit"]' not in facade_source + assert "deferring without runner-held sleep" in facade_source + assert "scheduler_outcome=deferred_rate_limit" in facade_source + assert "Required PR Review Merge Scheduler heartbeat" in facade_source + assert 'GITHUB_WORKFLOW", "") == "OpenCode Review Dispatch"' in facade_source + assert "__all__ = tuple(" in facade_source diff --git a/tests/test_strix_backend_unavailable_after_exempted_finding.py b/tests/test_strix_backend_unavailable_after_exempted_finding.py index f9b75e313d..029f43ec55 100644 --- a/tests/test_strix_backend_unavailable_after_exempted_finding.py +++ b/tests/test_strix_backend_unavailable_after_exempted_finding.py @@ -240,26 +240,6 @@ def test_bare_backend_outage_with_no_finding_is_non_passing( self.assertEqual(_run_gate_tail(GITHUB_MODELS_BROWNOUT), 1) - def test_exempted_finding_then_outage_recovers_on_second_attempt(self) -> None: - """An exempt finding before continuation must not block outage retry.""" - - gate = r"""#!/usr/bin/env bash -calls=$(( $(cat __COUNTER__) + 1 )) -echo "$calls" > __COUNTER__ -if [ "$calls" -le 1 ]; then - printf '%s\n' \ - "Strix findings are limited to unchanged files in this pull request; allowing pipeline continuation." \ - "LLM CONNECTION FAILED" \ - "Configured model and fallback models were unavailable." - exit 1 -fi -echo "scan complete" -exit 0 -""" - returncode, calls = _run_gate_retry(gate) - self.assertEqual(returncode, 0) - self.assertEqual(calls, 2) - def test_real_finding_after_continuation_never_retries(self) -> None: """A tail-scoped real finding is authoritative: zero retries, fail closed.""" @@ -276,12 +256,14 @@ def test_real_finding_after_continuation_never_retries(self) -> None: self.assertEqual(returncode, 1) self.assertEqual(calls, 1) - def test_retry_contract_preserves_logs_without_wall_clock_budget(self) -> None: - """Retries retain every attempt without imposing an inference deadline.""" + def test_workflow_uses_one_gateway_owned_attempt_without_wall_clock_budget(self) -> None: + """The workflow does not add retries or a repository-authored deadline.""" workflow = STRIX_WORKFLOW.read_text(encoding="utf-8") - self.assertIn('strix_attempt_log="$RUNNER_TEMP/strix_gate_console_attempt_', workflow) - self.assertIn('cat "$strix_attempt_log" >> "$strix_run_log"', workflow) + self.assertNotIn("strix_gate_attempt", workflow) + self.assertNotIn("STRIX_GATE_RETRY_BACKOFF_SECONDS", workflow) + self.assertNotIn("STRIX_TRANSIENT_RETRY_PER_MODEL:", workflow) + self.assertNotIn("STRIX_LLM_MAX_RETRIES:", workflow) self.assertNotIn("strix_gate_attempt_budget_seconds", workflow) self.assertNotIn("STRIX_PROCESS_TIMEOUT_SECONDS:", workflow) self.assertNotIn("STRIX_TOTAL_TIMEOUT_SECONDS:", workflow) diff --git a/tests/test_strix_caido_bootstrap_timing_retry.py b/tests/test_strix_caido_bootstrap_timing_retry.py index a60b9d801b..3bb7c9e220 100644 --- a/tests/test_strix_caido_bootstrap_timing_retry.py +++ b/tests/test_strix_caido_bootstrap_timing_retry.py @@ -138,5 +138,199 @@ def test_retry_reason_is_logged_for_operators(self) -> None: ) +RATE_LIMIT_LOG = ( + "litellm.RateLimitError: RateLimitError: rate limit exceeded\n" + "Vulnerabilities 0\n" +) + + +def _run_retry_loop(log_text: str, *, per_model: int, sandbox_retries: int) -> tuple[int, str]: + """Drive the production retry loop with a stubbed Strix run and return (calls, stderr). + + The reported sandbox retry count (``SANDBOX_RETRIES_USED``) is echoed to + stdout as ``reported=`` and appended to the returned stderr text so + tests can assert it without a second harness. + + ``run_strix_once`` is replaced by a stub that writes ``log_text`` to the + attempt log and fails, so the loop's own retry decision is what is under + test; every classifier the loop consults is the production function. + """ + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + blocks = [ + _function_block(gate_source, name) + for name in ( + "run_strix_with_transient_retry", + "is_transient_same_model_retry_error", + "is_timeout_error", + "is_llm_api_connection_error", + "is_llm_service_unavailable_error", + "is_rate_limit_error", + "is_midstream_fallback_error", + "is_caido_bootstrap_timing_error", + ) + ] + with tempfile.TemporaryDirectory(prefix="strix-caido-retry-") as temp_dir: + log_path = Path(temp_dir) / "strix.log" + counter = Path(temp_dir) / "calls" + counter.write_text("0", encoding="utf-8") + script = "\n".join( + ( + "set -uo pipefail", + f'STRIX_LOG="{log_path}"', + f'COUNTER="{counter}"', + f"STRIX_TRANSIENT_RETRY_PER_MODEL={per_model}", + f"STRIX_SANDBOX_BOOTSTRAP_RETRIES={sandbox_retries}", + "STRIX_TRANSIENT_RETRY_BACKOFF_SECONDS=0", + "STRIX_TOTAL_TIMEOUT_SECONDS=0", + "TOTAL_TIMEOUT_EXCEEDED=0", + "github_models_rate_limit_should_skip_same_model_retry() { return 1; }", + # The stub caps itself: a runaway loop returns the configuration + # exit code 2 after six calls, which the harness reports as a + # failure instead of hanging the suite. + 'run_strix_once() { n=$(( $(cat "$COUNTER") + 1 )); echo "$n" > "$COUNTER"; printf "%s" "$LOG_TEXT" > "$STRIX_LOG"; [ "$n" -ge 6 ] && return 2; return 1; }', + *blocks, + 'run_strix_with_transient_retry "orchestrator/free"; rc=$?; echo "reported=$SANDBOX_RETRIES_USED"; exit "$rc"', + ) + ) + completed = subprocess.run( + ["bash", "-c", script, "strix-retry"], + check=False, + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin", "LOG_TEXT": log_text}, + ) + calls = int(counter.read_text(encoding="utf-8").strip()) + if completed.returncode != 1: + raise AssertionError(f"rc={completed.returncode}\n{completed.stderr}") + return calls, completed.stderr + completed.stdout + + +def _orchestrator_verdict_line(log_text: str) -> str: + """Return the stderr the primary-scan verdict branch emits for a failed orchestrator scan.""" + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + blocks = [ + _function_block(gate_source, name) + for name in ("run_current_target_scan", "is_caido_bootstrap_timing_error") + ] + with tempfile.TemporaryDirectory(prefix="strix-caido-verdict-") as temp_dir: + log_path = Path(temp_dir) / "strix.log" + log_path.write_text(log_text, encoding="utf-8") + script = "\n".join( + ( + "set -uo pipefail", + f'STRIX_LOG="{log_path}"', + 'PRIMARY_MODEL="orchestrator/free"', + "STRIX_SANDBOX_BOOTSTRAP_RETRIES=1", + "SANDBOX_RETRIES_USED=1", + "TOTAL_TIMEOUT_EXCEEDED=0", + # run_current_target_scan resets INFRA_ERROR_DETECTED before the + # scan; the production run_strix_once sets it on a failed attempt, + # so the stub does the same. + "run_strix_with_transient_retry() { INFRA_ERROR_DETECTED=1; return 1; }", + "provider_signal_fail_closed_enabled() { return 0; }", + "is_contextual_orchestrator_model() { return 0; }", + "is_model_retryable_error() { return 1; }", + "has_distinct_fallback_model_for_model() { return 1; }", + # has_detected_infrastructure_error is consulted by run_strix_once, + # which the stub above replaces; the flag is set by that path. + *blocks, + "run_current_target_scan", + ) + ) + completed = subprocess.run( + ["bash", "-c", script, "strix-verdict"], + check=False, + capture_output=True, + text=True, + ) + if completed.returncode != 1: + raise AssertionError(f"rc={completed.returncode}\n{completed.stderr}") + return completed.stderr + + +class StrixSandboxBootstrapRetryAndVerdictTests(unittest.TestCase): + """The sandbox race gets its own bounded retry and its own name in the verdict. + + Evidence (2026-09-06): ``argos`` Strix run 34013128112 and a second + artifact both show a single attempt ending in ``loginAsGuest failed after + 10 attempts`` on ``127.0.0.1:48080`` after ``Docker image ready``, then + ``STRIX_PROVIDER_UNAVAILABLE: … orchestrator/free exhausted`` -- while the + sidecar had four ready and four deferred routes that were never called. + ``STRIX_TRANSIENT_RETRY_PER_MODEL`` defaults to 0 and the workflow does not + raise it, so the documented same-model retry for this class never ran. + """ + + def test_sandbox_bootstrap_failure_is_retried_once_even_with_zero_per_model_budget(self) -> None: + calls, stderr = _run_retry_loop(OBSERVED_LOG, per_model=0, sandbox_retries=1) + self.assertEqual(calls, 2) + self.assertIn("Caido sandbox bootstrap timing", stderr) + self.assertIn("attempt 2/2", stderr) + + def test_sandbox_retry_budget_is_bounded(self) -> None: + calls, _ = _run_retry_loop(OBSERVED_LOG, per_model=0, sandbox_retries=2) + self.assertEqual(calls, 3) + calls, _ = _run_retry_loop(OBSERVED_LOG, per_model=0, sandbox_retries=0) + self.assertEqual(calls, 1) + + def test_mixed_sandbox_and_gateway_log_stays_bounded(self) -> None: + """A log matching the sandbox class AND a gateway class grants at most the sandbox budget. + + Found by adversarial review of the first draft, which charged the + sandbox counter in the retry-reason chain behind the gateway classes: + such a log then extended the budget on every iteration without ever + charging it, and production bounds the loop with nothing but GitHub's + six-hour default. + """ + + calls, stderr = _run_retry_loop(RATE_LIMIT_LOG + OBSERVED_LOG, per_model=0, sandbox_retries=1) + self.assertEqual(calls, 2) + self.assertNotIn("attempt 3/", stderr) + + def test_sandbox_budget_is_granted_on_top_of_the_per_model_budget(self) -> None: + calls, _ = _run_retry_loop(OBSERVED_LOG, per_model=1, sandbox_retries=1) + self.assertEqual(calls, 3) + + def test_reported_sandbox_retries_count_only_retries_that_ran(self) -> None: + """A granted attempt vetoed by the timeout check is not reported as a retry. + + Lane peer 1's verification note: the budget is charged at the grant, + but ``is_transient_same_model_retry_error`` returns 1 for a timeout + signature, so a log carrying both the sandbox and a timeout signature + is granted, charged, and then not retried; the verdict must say 0. + """ + + calls, out = _run_retry_loop( + "litellm.exceptions.Timeout: request timed out\n" + OBSERVED_LOG, + per_model=0, + sandbox_retries=1, + ) + self.assertEqual(calls, 1) + self.assertIn("reported=0", out) + calls, out = _run_retry_loop(OBSERVED_LOG, per_model=0, sandbox_retries=1) + self.assertEqual(calls, 2) + self.assertIn("reported=1", out) + + def test_sandbox_retry_does_not_widen_gateway_retries(self) -> None: + """A rate limit from the gateway still gets no same-model retry at budget 0.""" + + calls, stderr = _run_retry_loop(RATE_LIMIT_LOG, per_model=0, sandbox_retries=1) + self.assertEqual(calls, 1) + self.assertNotIn("Retrying model", stderr) + + def test_verdict_names_the_sandbox_and_keeps_the_workflow_token(self) -> None: + stderr = _orchestrator_verdict_line(OBSERVED_LOG) + self.assertIn("STRIX_PROVIDER_UNAVAILABLE: STRIX_SANDBOX_UNAVAILABLE:", stderr) + self.assertIn("after 1 sandbox-specific same-model retries (budget 1)", stderr) + self.assertIn("names Strix's sandbox, not the LLM gateway", stderr) + self.assertNotIn("orchestrator/free exhausted", stderr) + + def test_verdict_for_a_gateway_failure_is_unchanged(self) -> None: + stderr = _orchestrator_verdict_line(RATE_LIMIT_LOG) + self.assertIn("orchestrator/free exhausted", stderr) + self.assertNotIn("STRIX_SANDBOX_UNAVAILABLE", stderr) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_strix_llm_timeout_contract.py b/tests/test_strix_llm_timeout_contract.py index 8661486441..b46630c898 100644 --- a/tests/test_strix_llm_timeout_contract.py +++ b/tests/test_strix_llm_timeout_contract.py @@ -127,7 +127,12 @@ def make_model_settings(*args, **kwargs): main_module.main = lambda: None core_package.inputs = inputs_module interface_package.scan_setup = scan_setup_module - interface_package.main = main_module + # Real strix/interface/__init__.py runs ``from .main import main``, which + # rebinds the package attribute to the *function*, shadowing the + # submodule of the same name. Replicate that shadow here so this test + # actually exercises the sys.modules lookup path instead of the + # attribute-traversal path a shadow-unaware fake would take. + interface_package.main = main_module.main strix_package.core = core_package strix_package.interface = interface_package @@ -373,7 +378,9 @@ def test_launcher_script_entrypoint_enters_patched_strix(monkeypatch) -> None: main_module.main = lambda: calls.append("main") core_package.inputs = inputs_module interface_package.scan_setup = scan_setup_module - interface_package.main = main_module + # Replicate strix/interface/__init__.py's ``from .main import main`` shadow + # (see the sibling test above) so this also exercises the real code path. + interface_package.main = main_module.main strix_package.core = core_package strix_package.interface = interface_package monkeypatch.setitem(sys.modules, "strix", strix_package) @@ -393,3 +400,48 @@ def test_launcher_script_entrypoint_enters_patched_strix(monkeypatch) -> None: runpy.run_path(str(LAUNCHER), run_name="__main__") assert calls == ["main"] + + +def test_runtime_compatibility_survives_the_package_level_main_shadow(monkeypatch) -> None: + """Regression: strix/interface/__init__.py's ``from .main import main`` shadows the + submodule as a package attribute, so attribute-traversal imports of + ``strix.interface.main`` return the function, not the module — this reproduces the + live crash (AttributeError: 'function' object has no attribute 'asyncio') seen in + production before the sys.modules lookup fix.""" + launcher = _load_launcher() + + strix_package = types.ModuleType("strix") + core_package = types.ModuleType("strix.core") + interface_package = types.ModuleType("strix.interface") + inputs_module = types.ModuleType("strix.core.inputs") + scan_setup_module = types.ModuleType("strix.interface.scan_setup") + main_module = types.ModuleType("strix.interface.main") + + inputs_module.make_model_settings = lambda *args, **kwargs: kwargs + scan_setup_module.asyncio = asyncio + main_module.asyncio = asyncio + main_module.main = lambda: None + core_package.inputs = inputs_module + interface_package.scan_setup = scan_setup_module + # The shadow itself: the package attribute is the bare function, exactly as + # ``from .main import main`` leaves it in the real strix-agent 1.5.3 package. + interface_package.main = main_module.main + strix_package.core = core_package + strix_package.interface = interface_package + + monkeypatch.setitem(sys.modules, "strix", strix_package) + monkeypatch.setitem(sys.modules, "strix.core", core_package) + monkeypatch.setitem(sys.modules, "strix.core.inputs", inputs_module) + monkeypatch.setitem(sys.modules, "strix.interface", interface_package) + monkeypatch.setitem(sys.modules, "strix.interface.scan_setup", scan_setup_module) + monkeypatch.setitem(sys.modules, "strix.interface.main", main_module) + monkeypatch.setattr(launcher, "_require_supported_version", lambda: None) + monkeypatch.setenv("LLM_TIMEOUT", "300") + monkeypatch.setenv("LLM_STREAM_IDLE_TIMEOUT", "300") + + assert isinstance(interface_package.main, types.FunctionType) + + result = launcher.install_runtime_compatibility() + + assert result is main_module + assert isinstance(main_module.asyncio, launcher.UnboundedInferenceAsyncio) diff --git a/tests/test_strix_model_behavior_error.py b/tests/test_strix_model_behavior_error.py index 0918be59f8..3d0fd0bc42 100644 --- a/tests/test_strix_model_behavior_error.py +++ b/tests/test_strix_model_behavior_error.py @@ -17,7 +17,10 @@ STRIX_GATE = REPOSITORY_ROOT / "scripts" / "ci" / "strix_quick_gate.sh" STRIX_WORKFLOW = REPOSITORY_ROOT / ".github" / "workflows" / "strix.yml" QUALITY_WORKFLOW = ( - REPOSITORY_ROOT / ".github" / "workflows" / "strix-changed-path-quality-ci.yml" + REPOSITORY_ROOT + / ".github" + / "workflows" + / "agent-review-runtime-quality-ci.yml" ) diff --git a/tests/test_strix_quality_timeout_fixture_budget.py b/tests/test_strix_quality_timeout_fixture_budget.py index 0ea4e3b37d..06b1025275 100644 --- a/tests/test_strix_quality_timeout_fixture_budget.py +++ b/tests/test_strix_quality_timeout_fixture_budget.py @@ -1,12 +1,20 @@ +"""Runtime-budget contracts for consolidated Strix quality validation.""" + from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[1] -WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "strix-changed-path-quality-ci.yml" +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = ( + REPOSITORY_ROOT + / ".github" + / "workflows" + / "agent-review-runtime-quality-ci.yml" +) def _named_step(workflow: str, name: str) -> str: """Return one exact named workflow step without loading workflow YAML tags.""" + marker = f" - name: {name}\n" start = workflow.index(marker) try: @@ -18,6 +26,7 @@ def _named_step(workflow: str, name: str) -> str: def test_strix_quality_uses_short_fake_process_timeouts() -> None: """Keep deterministic timeout fixtures well inside the quality-job budget.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") step = _named_step(workflow, "Verify exact-head path policy and syntax") @@ -28,6 +37,7 @@ def test_strix_quality_uses_short_fake_process_timeouts() -> None: def test_strix_quality_trigger_includes_fixture_contract_paths() -> None: """Keep fixture behavior and doctoring changes inside the quality trigger.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") trigger = workflow[: workflow.index("\njobs:")] @@ -39,6 +49,7 @@ def test_strix_quality_trigger_includes_fixture_contract_paths() -> None: def test_strix_quality_keeps_real_scanner_budgets_out_of_fixture_overrides() -> None: """Fixture acceleration must not weaken production Strix scanner timeouts.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") step = _named_step(workflow, "Verify exact-head path policy and syntax") diff --git a/tests/test_strix_recovered_transient_sanitizer.py b/tests/test_strix_recovered_transient_sanitizer.py new file mode 100644 index 0000000000..28fda47205 --- /dev/null +++ b/tests/test_strix_recovered_transient_sanitizer.py @@ -0,0 +1,284 @@ +"""Regression contract for strix-agent's recovered transient model errors. + +strix-agent 1.5.3 (``strix/core/execution.py:760-763``) retries a transient +model/provider error up to ``_MAX_TRANSIENT_MODEL_RETRIES`` times and, inside +that branch only, logs:: + + WARNING - strix.core.execution: transient model/provider error for + ; replaying turn (attempt n/m, backoff Ns): + +immediately before the replay runs. The line therefore means "a retry is +happening now", never "the scan failed". When the budget is exhausted the same +module logs ``agent run failed for ; marking failed`` at ERROR with a +traceback and the process exits non-zero. + +Observed on ContextualWisdomLab/.github#1689 run ``34013778497``: a completed +63-minute scan (``run.json`` status ``completed``, SARIF 0 results, attempt exit +code 0) was failed closed as ``STRIX_PROVIDER_UNAVAILABLE … exhausted`` because +three such WARNING lines survived ``sanitize_known_strix_report_warnings`` and +tripped ``has_strix_report_failure_signal``'s ``WARNING`` scan. + +Negative control, as measured by running this file against ``main``'s gate before +this change: **3 failed, 4 passed.** The three that fail are +``test_recovered_transient_replay_warnings_are_sanitized`` (the lines remain and +the failure signal fires), ``test_production_argument_shape_sanitizes_the_scanned_directory`` +(the same, through the narrowing branch), and +``test_unrecovered_transient_keeps_the_error_and_traceback`` on its first assertion +only, since ``assertNotIn("replaying turn", ...)`` also needs the new alternative +while its ERROR-and-traceback retention assertions hold on both gates. The four +that pass on both gates are the guards: the two unknown-warning cases, the +foreign-module case, and the pre-existing forced-continuation case. +""" + +from __future__ import annotations + +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +STRIX_GATE = REPOSITORY_ROOT / "scripts" / "ci" / "strix_quick_gate.sh" + +_PREFIX = "strix-pr-scope-qd1fsv_9ee6 - strix.core.execution: " + +# The three lines exactly as the run above wrote them (490 characters each). +_REPR = ( + "InternalServerError(\"Error code: 500 - {'error': {'code': 'internal_error', " + "'message': 'internal server error', 'detail': {'request_id': '%s'}}, " + "'error_code': 'internal_error', 'error_message': 'internal server error', " + "'error_detail': {'request_id': '%s'}}\")" +) +RECOVERED_LOG = ( + "2026-09-06 07:23:08.199 WARNING " + _PREFIX + + "transient model/provider error for 76d3c83d; replaying turn " + "(attempt 1/5, backoff 2.0s): " + + _REPR % ("466c7aee94e24a6e811cbd7fd12bc1a9", "466c7aee94e24a6e811cbd7fd12bc1a9") + + "\n" + "2026-09-06 07:23:10.205 DEBUG strix-pr-scope-qd1fsv_9ee6 - " + "strix.llm.context_budget: No LiteLLM model info for 'openai/orchestrator/free'; " + "using configured fallbacks\n" + "2026-09-06 07:45:20.154 WARNING " + _PREFIX + + "transient model/provider error for 76d3c83d; replaying turn " + "(attempt 2/5, backoff 4.0s): " + + _REPR % ("6dbf7b28ee16448592e10bb9728a523f", "6dbf7b28ee16448592e10bb9728a523f") + + "\n" + "2026-09-06 07:58:54.623 WARNING " + _PREFIX + + "transient model/provider error for 76d3c83d; replaying turn " + "(attempt 3/5, backoff 8.0s): " + + _REPR % ("a85b9828eb754e129f62d202359ea316", "a85b9828eb754e129f62d202359ea316") + + "\n" + "2026-09-06 08:09:35.584 INFO strix-pr-scope-qd1fsv_9ee6 - " + "strix.core.runner: Strix scan strix-pr-scope-qd1fsv_9ee6 done\n" +) + +# After the bounded budget is spent strix-agent logs at ERROR with a traceback +# (observed on a same-day run) and exits non-zero. The sanitizer must leave it. +UNRECOVERED_LOG = ( + "2026-09-06 07:24:31.010 WARNING strix-pr-scope-5p3h3c_e0d0 - " + "strix.core.execution: transient model/provider error for 6c480eb0; " + "replaying turn (attempt 5/5, backoff 32.0s): InternalServerError(\"Error code: 500\")\n" + "2026-09-06 07:24:40.562 ERROR strix-pr-scope-5p3h3c_e0d0 - " + "strix.core.execution: agent run failed for 6c480eb0; marking failed\n" + "Traceback (most recent call last):\n" + ' File "/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/' + 'strix/core/execution.py", line 676, in _run_cycle\n' + " async for event in stream.stream_events():\n" + "openai.InternalServerError: Error code: 500\n" +) + +UNKNOWN_WARNING_LOG = ( + "2026-09-06 07:30:00.000 WARNING strix-pr-scope-qd1fsv_9ee6 - " + "strix.core.execution: transient model/provider error for 76d3c83d; " + "giving up after 5 attempts\n" +) + +# A different module echoing the same words must not be sanitized: the anchor +# is the logger name, not the phrase. +FOREIGN_MODULE_LOG = ( + "2026-09-06 07:30:00.000 WARNING strix-pr-scope-qd1fsv_9ee6 - " + "strix.tools.browser: transient model/provider error for 76d3c83d; " + "replaying turn (attempt 1/5, backoff 2.0s): Timeout\n" +) + +LEGACY_LOG = ( + "2026-06-18 13:08:05.986 WARNING strix-pr-scope-example - strix.core.execution: " + "agent a9fb4033 produced non-lifecycle final output in non-interactive mode; " + "forcing tool continuation (1/3): {'x': 1}\n" + "2026-08-22 09:53:26.193 WARNING strix-pr-scope-example - strix.core.execution: " + "agent 673f770f ended a turn without a lifecycle tool call (interactive=False); " + "forcing tool continuation (2/3): done\n" + "2026-06-18 13:10:44.089 INFO strix-pr-scope-example - strix.tools.finish.tool: " + "finish_scan: completed scan with 0 vulnerability report(s)\n" +) + + +def _function_block(source: str, function_name: str) -> str: + """Return one top-level Bash function, including its closing brace.""" + + match = re.search( + rf"(?ms)^{re.escape(function_name)}\(\) \{{\n.*?^\}}\n", + source, + ) + if match is None: + raise AssertionError(f"missing Bash function: {function_name}") + return match.group(0) + + +def _sanitize_then_signal(log_text: str) -> tuple[str, bool]: + """Run the production sanitizer, then the production failure-signal scan. + + Returns the report log's remaining text and whether + ``has_strix_report_failure_signal`` still fires on it. The report root is a + plain temp directory, so the function's ``STRIX_REPORTS_DIR`` branch + (which resolves the newest run) is not taken and needs no helper. + """ + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + blocks = [ + _function_block(gate_source, name) + for name in ( + "sanitize_known_strix_report_warnings", + "has_strix_report_failure_signal", + ) + ] + with tempfile.TemporaryDirectory(prefix="strix-recovered-transient-") as temp_dir: + report_root = Path(temp_dir) / "strix_runs" / "strix-pr-scope-qd1fsv_9ee6" + report_root.mkdir(parents=True) + log_path = report_root / "strix.log" + log_path.write_text(log_text, encoding="utf-8") + script = "\n".join( + ( + "set -uo pipefail", + 'STRIX_REPORTS_DIR="/nonexistent/strix-reports"', + *blocks, + 'sanitize_known_strix_report_warnings "$1"', + 'if has_strix_report_failure_signal "$1"; then echo signal=1; else echo signal=0; fi', + ) + ) + completed = subprocess.run( + ["bash", "-c", script, "strix-sanitizer", str(report_root)], + check=False, + capture_output=True, + text=True, + ) + remaining = log_path.read_text(encoding="utf-8") + if completed.returncode != 0: + raise AssertionError(f"rc={completed.returncode}\n{completed.stderr}") + return remaining, "signal=1" in completed.stdout + + +def _sanitize_then_signal_production_shape(log_text: str) -> tuple[str, bool]: + """Same sequence with the argument shape production actually uses. + + Production passes ``ACTIVE_REPORTS_DIR``, which equals ``STRIX_REPORTS_DIR``, + so ``has_strix_report_failure_signal`` takes its narrowing branch and scans + only ``latest_strix_report_dir``'s newest run directory. ``_sanitize_then_signal`` + hands in that run directory directly and therefore skips the branch; this + helper covers it, so the pair proves the sanitized tree and the scanned tree + are the same one. + """ + + gate_source = STRIX_GATE.read_text(encoding="utf-8") + blocks = [ + _function_block(gate_source, name) + for name in ( + "sanitize_known_strix_report_warnings", + "has_strix_report_failure_signal", + "latest_strix_report_dir", + "is_preexisting_report_dir", + ) + ] + with tempfile.TemporaryDirectory(prefix="strix-recovered-transient-prod-") as temp_dir: + reports_root = Path(temp_dir) / "reports" + run_dir = reports_root / "strix-pr-scope-qd1fsv_9ee6" + run_dir.mkdir(parents=True) + log_path = run_dir / "strix.log" + log_path.write_text(log_text, encoding="utf-8") + script = "\n".join( + ( + "set -uo pipefail", + f'STRIX_REPORTS_DIR="{reports_root}"', + # Non-empty so "${PREEXISTING_REPORT_DIRS[@]}" is safe under set -u. + 'PREEXISTING_REPORT_DIRS=("/nonexistent/preexisting")', + *blocks, + 'sanitize_known_strix_report_warnings "$STRIX_REPORTS_DIR"', + 'if has_strix_report_failure_signal "$STRIX_REPORTS_DIR"; then echo signal=1; else echo signal=0; fi', + ) + ) + completed = subprocess.run( + ["bash", "-c", script, "strix-sanitizer-prod"], + check=False, + capture_output=True, + text=True, + ) + remaining = log_path.read_text(encoding="utf-8") + if completed.returncode != 0: + raise AssertionError(f"rc={completed.returncode}\n{completed.stderr}") + return remaining, "signal=1" in completed.stdout + + +class StrixRecoveredTransientSanitizerTests(unittest.TestCase): + """Keep a recovered transient model error from failing a completed scan.""" + + def test_recovered_transient_replay_warnings_are_sanitized(self) -> None: + """The three observed lines are removed and the WARNING scan stays quiet.""" + + remaining, signal = _sanitize_then_signal(RECOVERED_LOG) + self.assertNotIn("replaying turn", remaining) + self.assertNotIn("InternalServerError", remaining) + self.assertIn("strix.core.runner: Strix scan strix-pr-scope-qd1fsv_9ee6 done", remaining) + self.assertIn("strix.llm.context_budget", remaining) + self.assertFalse(signal) + + def test_unrecovered_transient_keeps_the_error_and_traceback(self) -> None: + """Only the retry line goes; the ERROR record and its traceback stay for the rc!=0 path.""" + + remaining, _signal = _sanitize_then_signal(UNRECOVERED_LOG) + self.assertNotIn("replaying turn", remaining) + self.assertIn("agent run failed for 6c480eb0; marking failed", remaining) + self.assertIn("Traceback (most recent call last):", remaining) + self.assertIn("openai.InternalServerError: Error code: 500", remaining) + + def test_unknown_execution_warning_still_fails_closed(self) -> None: + """A WARNING from the same logger with a different message is not sanitized.""" + + remaining, signal = _sanitize_then_signal(UNKNOWN_WARNING_LOG) + self.assertEqual(remaining, UNKNOWN_WARNING_LOG) + self.assertTrue(signal) + + def test_same_words_from_another_module_still_fail_closed(self) -> None: + """The anchor is the strix.core.execution logger, not the phrase.""" + + remaining, signal = _sanitize_then_signal(FOREIGN_MODULE_LOG) + self.assertEqual(remaining, FOREIGN_MODULE_LOG) + self.assertTrue(signal) + + def test_production_argument_shape_sanitizes_the_scanned_directory(self) -> None: + """With the reports root passed as production passes it, the narrowed scan is quiet.""" + + remaining, signal = _sanitize_then_signal_production_shape(RECOVERED_LOG) + self.assertNotIn("replaying turn", remaining) + self.assertIn("strix.core.runner: Strix scan strix-pr-scope-qd1fsv_9ee6 done", remaining) + self.assertFalse(signal) + + def test_production_argument_shape_still_fails_closed_on_an_unknown_warning(self) -> None: + """The narrowing branch does not swallow a warning the sanitizer does not know.""" + + remaining, signal = _sanitize_then_signal_production_shape(UNKNOWN_WARNING_LOG) + self.assertEqual(remaining, UNKNOWN_WARNING_LOG) + self.assertTrue(signal) + + def test_existing_forced_continuation_warnings_remain_sanitized(self) -> None: + """The two pre-existing alternatives keep working after the regex restructure.""" + + remaining, signal = _sanitize_then_signal(LEGACY_LOG) + self.assertNotIn("forcing tool continuation", remaining) + self.assertIn("finish_scan: completed scan with 0 vulnerability report(s)", remaining) + self.assertFalse(signal) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_strix_rerun_job_selection.py b/tests/test_strix_rerun_job_selection.py index ab6d7ba4c6..c1926b2ce3 100644 --- a/tests/test_strix_rerun_job_selection.py +++ b/tests/test_strix_rerun_job_selection.py @@ -38,6 +38,7 @@ def record_rerun(repo: str, job_id: str, *, dry_run: bool, action: str) -> None: reruns.append((repo, job_id, action)) monkeypatch.setattr(sched, "rerun_actions_job", record_rerun) + monkeypatch.setattr(sched, "fetch_pr", lambda *_args: [pr]) assert ( sched.dispatch_strix_evidence( diff --git a/tests/test_strix_runtime_dependencies.py b/tests/test_strix_runtime_dependencies.py new file mode 100644 index 0000000000..fd66f8d452 --- /dev/null +++ b/tests/test_strix_runtime_dependencies.py @@ -0,0 +1,17 @@ +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def test_strix_installs_openai_httpx2_runtime() -> None: + requirements = (REPOSITORY_ROOT / "requirements-strix-ci.txt").read_text( + encoding="utf-8" + ) + requirements_lock = ( + REPOSITORY_ROOT / "requirements-strix-ci-hashes.txt" + ).read_text(encoding="utf-8") + + assert "openai[httpx2]==2.54.0" in requirements.splitlines() + assert "openai==2.54.0 \\" in requirements_lock.splitlines() + assert "httpx2==2.12.0 \\" in requirements_lock.splitlines() diff --git a/tests/test_strix_workflow_dependency_hashes.py b/tests/test_strix_workflow_dependency_hashes.py index e2509c18b8..2f8e9706a1 100644 --- a/tests/test_strix_workflow_dependency_hashes.py +++ b/tests/test_strix_workflow_dependency_hashes.py @@ -1,4 +1,4 @@ -"""Supply-chain contracts for the Strix changed-path policy workflow.""" +"""Supply-chain contracts for the consolidated agent review quality workflow.""" from pathlib import Path import re @@ -6,8 +6,13 @@ import pytest -ROOT = Path(__file__).resolve().parents[1] -WORKFLOW = ROOT / ".github" / "workflows" / "strix-changed-path-quality-ci.yml" +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = ( + REPOSITORY_ROOT + / ".github" + / "workflows" + / "agent-review-runtime-quality-ci.yml" +) WORKFLOW_DISPATCH_KEY_RE = re.compile( r"(?m)^[ \t]+['\"]?workflow_dispatch['\"]?\s*:" ) @@ -22,8 +27,9 @@ def test_strix_workflow_installs_only_hash_verified_wheels() -> None: - """Every network-installed test dependency is versioned and hash verified.""" - workflow = WORKFLOW.read_text(encoding="utf-8") + """Every network-installed base test dependency is versioned and hashed.""" + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") assert "--only-binary=:all:" in workflow assert "--require-hashes" in workflow @@ -35,14 +41,16 @@ def test_strix_workflow_installs_only_hash_verified_wheels() -> None: def test_strix_workflow_reruns_when_hash_contract_changes() -> None: """Changing this regression contract must trigger the exact-head workflow.""" - workflow = WORKFLOW.read_text(encoding="utf-8") + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") assert ' - "tests/test_strix_workflow_dependency_hashes.py"' in workflow def test_strix_workflow_rejects_branch_selected_manual_dispatch() -> None: """Central executable workflows load no branch-selected manual source.""" - workflow = WORKFLOW.read_text(encoding="utf-8") + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") assert WORKFLOW_DISPATCH_KEY_RE.search(workflow) is None @@ -59,7 +67,8 @@ def test_strix_workflow_rejects_branch_selected_manual_dispatch() -> None: def test_manual_dispatch_guard_recognizes_valid_yaml_key_spellings( yaml_key: str, ) -> None: - """The manual-dispatch guard must recognize equivalent YAML key spellings.""" + """The guard must recognize equivalent YAML key spellings.""" + synthetic_workflow = f"on:\n {yaml_key}\n" assert WORKFLOW_DISPATCH_KEY_RE.search(synthetic_workflow) is not None @@ -67,7 +76,8 @@ def test_manual_dispatch_guard_recognizes_valid_yaml_key_spellings( def test_strix_workflow_runs_complete_shell_regression_suite() -> None: """Run and retrigger on the shell regressions that pytest cannot collect.""" - workflow = WORKFLOW.read_text(encoding="utf-8") + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") assert ' - "scripts/ci/test_strix_quick_gate.sh"' in workflow assert "bash scripts/ci/test_strix_quick_gate.sh" in workflow diff --git a/tests/test_verify_exact_artifact_sbom_handoff.py b/tests/test_verify_exact_artifact_sbom_handoff.py index 2c8f6658d0..192ff20017 100644 --- a/tests/test_verify_exact_artifact_sbom_handoff.py +++ b/tests/test_verify_exact_artifact_sbom_handoff.py @@ -58,13 +58,12 @@ def _write_json(path: Path, value: object) -> None: def _identity(arguments: argparse.Namespace) -> dict[str, object]: - """Return the exact identity document expected by the verifier.""" + """Return the pre-upload identity document expected by the verifier.""" return { "schema_version": "1.0", "source_repository": arguments.source_repository, "source_sha": arguments.source_sha, "evidence_artifact_name": arguments.evidence_artifact_name, - "evidence_artifact_digest": arguments.evidence_artifact_digest, "predicate_type": arguments.predicate_type, "cyclonedx_schema": arguments.cyclonedx_schema, "artifacts": { @@ -177,6 +176,22 @@ def test_valid_handoff_is_verified_and_manifest_is_deterministic(tmp_path: Path) assert output.read_text(encoding="utf-8").endswith("\n") +def test_outer_artifact_digest_can_arrive_after_inner_identity_is_sealed( + tmp_path: Path, +) -> None: + """Keep the GitHub upload receipt outside the bytes whose digest it describes.""" + arguments = _valid_handoff(tmp_path) + identity_path = Path(arguments.evidence_root, "source-identity.json") + sealed_identity_digest = _digest(identity_path) + identity = json.loads(identity_path.read_text(encoding="utf-8")) + + assert "evidence_artifact_digest" not in identity + arguments.evidence_artifact_digest = "sha256:" + ("c" * 64) + + verifier.verify(arguments) + assert _digest(identity_path) == sealed_identity_digest + + def test_main_prints_success_and_returns_zero( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: @@ -562,4 +577,4 @@ def test_resealed_unexpected_predicate_is_rejected_before_signing(tmp_path: Path _rewrite_checksums(root, arguments) with pytest.raises(verifier.EvidenceError, match="canonical CycloneDX predicate"): - verifier.verify(arguments) + verifier.verify(arguments) \ No newline at end of file