diff --git a/.github/workflows/noema-review.yml b/.github/workflows/noema-review.yml index f8ab55c896..5b0a8b0b89 100644 --- a/.github/workflows/noema-review.yml +++ b/.github/workflows/noema-review.yml @@ -53,21 +53,41 @@ jobs: 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 || '' }} + EXPECTED_BASE_REF: ${{ github.event.pull_request.base.ref || github.event.client_payload.pr_base_ref || '' }} + LEGACY_BASE_REF: ${{ github.event.client_payload.base_branch || '' }} + EXPECTED_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha || '' }} steps: - name: Admit only the exact live Noema head id: live_head run: | set -euo pipefail echo "admitted=false" >>"$GITHUB_OUTPUT" + resolved_base_ref="${EXPECTED_BASE_REF:-${LEGACY_BASE_REF:-}}" + if [ -n "${EXPECTED_BASE_REF:-}" ] && [ -n "${LEGACY_BASE_REF:-}" ] && + [ "$EXPECTED_BASE_REF" != "$LEGACY_BASE_REF" ]; then + echo "::error::Noema admission rejected conflicting base references." + exit 1 + fi if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || + ! [[ "${EXPECTED_BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || + [ -z "$resolved_base_ref" ] || + ! git check-ref-format "refs/heads/$resolved_base_ref" >/dev/null; 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 ! jq -e --arg target "$TARGET_REPOSITORY" --argjson number "$PR_NUMBER" \ + --arg ref "$resolved_base_ref" --arg sha "$EXPECTED_BASE_SHA" ' + .number == $number and .base.repo.full_name == $target + and .base.ref == $ref and .base.sha == $sha + ' <<<"$live_pr" >/dev/null; then + echo "::error::Noema admission rejected missing or mismatched live base identity." + exit 1 + fi if [ "${live_head,,}" != "${EXPECTED_HEAD_SHA,,}" ] || [ "$live_state" != "open" ]; then echo "::notice::Noema admission retired a stale trigger before review queue entry." exit 0 @@ -296,6 +316,9 @@ jobs: 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 || '' }} + EXPECTED_BASE_REF: ${{ github.event.pull_request.base.ref || github.event.client_payload.pr_base_ref || '' }} + LEGACY_BASE_REF: ${{ github.event.client_payload.base_branch || '' }} + EXPECTED_BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.client_payload.pr_base_sha || '' }} steps: - name: Skip events without pull request context if: env.PR_NUMBER == '' @@ -586,11 +609,30 @@ jobs: GH_TOKEN: ${{ secrets.NOEMA_REVIEW_TOKEN || steps.noema_github_app_token.outputs.token || steps.noema_oidc_token.outputs.token }} run: | set -euo pipefail - if ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Noema expected head must be a full commit SHA." + resolved_base_ref="${EXPECTED_BASE_REF:-${LEGACY_BASE_REF:-}}" + if [ -n "${EXPECTED_BASE_REF:-}" ] && [ -n "${LEGACY_BASE_REF:-}" ] && + [ "$EXPECTED_BASE_REF" != "$LEGACY_BASE_REF" ]; then + echo "::error::Noema review rejected conflicting base references." + exit 1 + fi + if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || + ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || + ! [[ "${EXPECTED_BASE_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || + [ -z "$resolved_base_ref" ] || + ! git check-ref-format "refs/heads/$resolved_base_ref" >/dev/null; then + echo "::error::Noema review rejected malformed pull request metadata." exit 1 fi pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + if ! jq -e --arg target "$TARGET_REPOSITORY" --argjson number "$PR_NUMBER" \ + --arg ref "$resolved_base_ref" --arg sha "$EXPECTED_BASE_SHA" ' + .number == $number and .base.repo.full_name == $target + and .base.ref == $ref and .base.sha == $sha + ' <<<"$pull_request_json" >/dev/null; then + echo "::error::Noema review rejected missing or mismatched live base identity." + exit 1 + fi live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" live_head_sha="$(jq -r '.head.sha // empty' <<<"$pull_request_json")" if [ "$live_state" != "open" ] || [ "${live_head_sha,,}" != "${EXPECTED_HEAD_SHA,,}" ]; then diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index d86497b3f4..50224888f2 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -7718,6 +7718,8 @@ jobs: GH_REPOSITORY: ${{ needs.validate-pr-metadata.outputs.target_repository }} PR_NUMBER: ${{ needs.validate-pr-metadata.outputs.pr_number }} PR_HEAD_SHA: ${{ needs.validate-pr-metadata.outputs.head_sha }} + PR_BASE_REF: ${{ needs.validate-pr-metadata.outputs.base_ref }} + PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }} OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt OPENCODE_ARTIFACT_MANIFEST_SHA256: ${{ steps.seal_artifacts.outputs.manifest_sha256 }} OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head @@ -7725,13 +7727,15 @@ jobs: run: | set -euo pipefail if [ -z "${GH_TOKEN:-}" ]; then - echo "::warning::Noema handoff skipped because no target-repository dispatch credential was available." + echo "::warning::Noema handoff skipped because no central dispatch credential was available." exit 1 fi python3 scripts/ci/noema_review_handoff.py \ --repo "$GH_REPOSITORY" \ --pr-number "$PR_NUMBER" \ --head-sha "$PR_HEAD_SHA" \ + --base-ref "$PR_BASE_REF" \ + --base-sha "$PR_BASE_SHA" \ --attempts 90 \ --interval-seconds 10 diff --git a/AGENTS.md b/AGENTS.md index 0972af51c5..ad9b7873c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,8 @@ The materialization contract is also covered by [`docs/doctoring/exact-artifact- ## Verification discipline +- Noema handoff는 중앙 `repository_dispatch` 수신 위치와 target PR의 base ref/SHA·head를 함께 검증한다. `pr_base_ref`와 기존 `base_branch`가 함께 있으면 일치해야 하며, admission 이후 모델 실행 직전에도 live base를 다시 확인한다. HTTP 204는 리뷰 완료 증거가 아니다. 재현과 한계는 [handoff runbook](docs/doctoring/noema-central-handoff-base-binding.md)을 따른다. + - producer가 안전한 로그 필드를 추가하면 exact revision 쌍으로 consumer sanitizer를 통과시켜 allowlist의 누락을 확인한다. producer 단위 테스트 성공만으로 CI artifact 보존을 주장하지 않으며, 연결 검증에서도 raw 본문 비출력을 유지한다. Many agent sessions work this organization concurrently under the same standing diff --git a/CLAUDE.md b/CLAUDE.md index 7dde78f5d2..b8532f782c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,8 @@ repeatable compile command. ## Conventions and gotchas specific to this repo +- Noema handoff의 중앙 수신 위치와 target PR base/head를 함께 확인한다. 기존 `base_branch` 호환성을 유지하되 `pr_base_ref`와 충돌하면 거절한다. Dispatch 접수는 완료가 아니며, [handoff runbook](docs/doctoring/noema-central-handoff-base-binding.md)의 실제 shell 회귀와 exact-head 리뷰 검증을 유지한다. + - **Contract tests pin workflows AND prose.** `tests/` asserts exact strings and structure of `PR_GOVERNANCE_AUDIT.md`, `docs/org-required-workflow-rollout.md`, `opencode.jsonc`, and several workflow files (e.g. `test_pr_governance_audit_contract.py`, `test_codeql_pr_workflow_contract.py`, diff --git a/docs/doctoring/noema-central-handoff-base-binding.md b/docs/doctoring/noema-central-handoff-base-binding.md new file mode 100644 index 0000000000..1785cf4d0c --- /dev/null +++ b/docs/doctoring/noema-central-handoff-base-binding.md @@ -0,0 +1,65 @@ +# Noema 중앙 handoff와 live base 검증 + +## 원인과 수정 범위 + +기준 소스는 `fb17ef556f94f673234aa557254ae52779e9a7b0`이다. +`scripts/ci/noema_review_handoff.py`는 소비자 저장소의 dispatch endpoint에 +`noema-review`를 보냈지만, 이 이벤트의 수신 workflow는 중앙 `.github`에 있다. +소비자의 HTTP 204 응답만으로 중앙 리뷰가 시작됐다고 볼 수 없다. + +송신 위치를 `repos/ContextualWisdomLab/.github/dispatches`로 맞추고, +OpenCode의 검증된 metadata 출력에서 target repository, PR number, +base ref/SHA, head SHA를 전달한다. 중앙 수신기는 최초 admission과 기존 +모델 직전 live PR 조회에서 같은 입력을 확인한다. 대기 중 base가 바뀌어도 +예전 입력으로 모델 실행을 허용하지 않는다. + +정식 필드는 `pr_base_ref`이고, 기존 agent-mention 송신자의 `base_branch`도 +허용한다. 두 값이 모두 있으면 같아야 한다. 누락·잘못된 형식·live PR의 +repository/number/base/head 불일치는 fail closed이며 기본 base를 지어내지 않는다. +기존 `pull_request_target` 경로는 event의 PR base/head를 사용한다. +이 수신 workflow에 `workflow_call` 또는 `pull_request` trigger를 새로 추가하지 않는다. + +## 재현과 검증 + +`tests/test_noema_review_handoff.py`는 실제 송신 함수를 주입 runner로 실행하고 +중앙 endpoint와 전체 payload를 확인한다. 수신기 검증은 기존 workflow shell +추출기를 재사용해 실제 두 `run:` 블록을 실행한다. fake `gh`는 지정된 PR GET만 +허용하며 외부 API를 호출하지 않는다. canonical/legacy/both 양성과 충돌·누락· +malformed·base 변경·다른 repository/PR/head 음성을 함께 검증한다. + +최초 endpoint/admission RED는 11 failed, 4 passed였다. 모델 직전 검사에도 +같은 회귀를 연결한 RED는 10 failed, 18 passed였다. 초기 fixture 목록의 +SyntaxError와 shell 추출 오류는 별도 테스트 작성 오류였으며 생산 결함의 RED에 +포함하지 않는다. 정확한 최종 실행 명령과 결과는 PR receipt에 기록한다. + +최종 영향 범위는 18개 기존 테스트 파일이다. normal과 `GITHUB_ACTIONS=true`에서 +각각 `-W error`로 788 passed, 1 skipped를 확인했다(8파일 308 + 인접 7파일 +135/1 skipped + scheduler 3파일 345). 전체 저장소 suite 결과로 확대하지 않는다. +handoff 모듈만 별도 측정한 coverage는 154 statements, 50 branches 모두 100%다. + +로컬 actionlint 1.7.12의 두 파일 동시 검사는 출력 없이 condition wait에 머물렀다. +자식 process가 없음을 확인하고 본인 실행 PID 46672만 종료했다(exit 143). +`GOMAXPROCS=2` 재시도 PID 53592도 같은 증상으로 종료했다(exit 143). +다른 세션의 PID 89987은 변경하지 않았다. 이후 `gtimeout 30` 단일 파일 검사에서 +Noema는 exit 0, OpenCode는 exit 124였고, **기준 fb17ef 원본 OpenCode도** +`gtimeout 20`에서 exit 124였다. 정지 원인 자체는 미확정이다. + +따라서 OpenCode 전체 외부 검사 완료를 주장하지 않는다. 두 파일의 actionlint +native YAML/expression 검사(`-shellcheck= -pyflakes=`)는 exit 0이고, 기존 +추출기로 읽은 변경된 세 shell 블록의 ShellCheck는 각각 exit 0이다. 기존 shell +syntax/queue 계약과 위 normal/CI 회귀도 통과했다. 재발 시 기준·변경본의 동일 +검사를 비교하고, 제한 시간 종료나 출력 부재를 통과로 세지 않는다. + +## 남은 경계와 운영 확인 + +HTTP 204 뒤에도 기존 trusted publisher의 exact-head terminal review를 확인해야 +하며, 접수·queued·모델 시작을 승인으로 바꾸지 않는다. 이 변경은 리뷰 자체의 +base/workflow/run provenance를 새로 증명하지 않는다. webhook 설정 소유권, +Strix→OpenCode→Noema 순차 admission, provider runtime 502 복구도 별도 작업이다. +ContextualWisdomLab/.github#2051의 remote terminal model-run binding도 이 변경에 +포함하지 않는다. +토큰 선택, 권한, concurrency, 재시도/대기 시간, free-only/ZDR 정책은 바꾸지 않는다. + +배포 후 운영자는 중앙 receiver run의 target PR metadata와 실제 terminal review를 +각각 확인해야 한다. 로컬 mock 테스트는 배포·credential 접근·실제 모델 완료 +증거가 아니다. 실패 시 원래 진단을 보존하고 무조건 재dispatch하지 않는다. diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..f6a2ca1541 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -74,6 +74,12 @@ flowchart LR ## 3. Gap register +2026-09-12 G-02 후속: `fb17ef556f94f673234aa557254ae52779e9a7b0`에서 +OpenCode→Noema 송신 endpoint가 중앙 수신기와 달랐고 base identity 전달·검증이 +빠져 있었다. [중앙 handoff 수리](doctoring/noema-central-handoff-base-binding.md)는 +송수신 계약과 live base 검증에 한정한다. 로컬 검증은 hosted 리뷰 완료나 provider +복구가 아니며, 기존 리뷰의 base/workflow/run provenance는 여전히 별도 검증 대상이다. + 우선순위는 구매자 체감, 보안/증거 위험, 선행 의존성 순서다. | Gap ID | 현재 관측 | 구매자 영향 | 우선 구현/검증 | diff --git a/scripts/ci/noema_review_handoff.py b/scripts/ci/noema_review_handoff.py index 29f6142a98..fb892aa4d9 100644 --- a/scripts/ci/noema_review_handoff.py +++ b/scripts/ci/noema_review_handoff.py @@ -193,19 +193,35 @@ def dispatch_noema( number: int, head_sha: str, *, + base_ref: str, + base_sha: str, runner: GhRunner = run_gh, ) -> None: - """Dispatch the target repository's default-branch Noema workflow.""" + """Dispatch the central default-branch receiver with exact target inputs.""" + if ( + not REPOSITORY_RE.fullmatch(repo) + or number < 1 + or not SHA_RE.fullmatch(head_sha) + or not SHA_RE.fullmatch(base_sha) + or not base_ref + or subprocess.run( + ["git", "check-ref-format", f"refs/heads/{base_ref}"], + check=False, capture_output=True, + ).returncode != 0 + ): + raise ValueError("Noema dispatch requires valid exact pull request identity") payload = { "event_type": "noema-review", "client_payload": { "target_repository": repo, "pr_number": number, "pr_head_sha": head_sha, + "pr_base_ref": base_ref, + "pr_base_sha": base_sha, }, } runner( - ["api", "-X", "POST", f"repos/{repo}/dispatches", "--input", "-"], + ["api", "-X", "POST", "repos/ContextualWisdomLab/.github/dispatches", "--input", "-"], json.dumps(payload), ) @@ -222,6 +238,8 @@ def run_handoff( number: int, head_sha: str, *, + base_ref: str, + base_sha: str, attempts: int, interval_seconds: float, runner: GhRunner = run_gh, @@ -292,7 +310,7 @@ def run_handoff( if not dispatched: try: - dispatch_noema(repo, number, head_sha, runner=runner) + dispatch_noema(repo, number, head_sha, base_ref=base_ref, base_sha=base_sha, runner=runner) except RuntimeError as exc: consecutive_failures += 1 detail = redact_text(str(exc)).strip() or "GitHub API call failed" @@ -347,6 +365,8 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--repo", required=True) parser.add_argument("--pr-number", required=True, type=int) parser.add_argument("--head-sha", required=True) + parser.add_argument("--base-ref", required=True) + parser.add_argument("--base-sha", required=True) parser.add_argument("--attempts", type=int, default=90) parser.add_argument("--interval-seconds", type=float, default=10.0) args = parser.parse_args(argv) @@ -356,6 +376,10 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.error("--pr-number must be positive") if not SHA_RE.fullmatch(args.head_sha): parser.error("--head-sha must be a 40-character Git SHA") + if not SHA_RE.fullmatch(args.base_sha): + parser.error("--base-sha must be a 40-character Git SHA") + if not args.base_ref: + parser.error("--base-ref must not be empty") if args.attempts < 1: parser.error("--attempts must be positive") if args.interval_seconds < 0: @@ -370,6 +394,8 @@ def main(argv: Sequence[str] | None = None) -> int: args.repo, args.pr_number, args.head_sha, + base_ref=args.base_ref, + base_sha=args.base_sha, attempts=args.attempts, interval_seconds=args.interval_seconds, ) diff --git a/tests/test_noema_orchestrator_workflow_contract.py b/tests/test_noema_orchestrator_workflow_contract.py index 628fa3cbc1..3a8f97dc16 100644 --- a/tests/test_noema_orchestrator_workflow_contract.py +++ b/tests/test_noema_orchestrator_workflow_contract.py @@ -301,7 +301,7 @@ def test_noema_admission_retires_out_of_order_dispatch_before_concurrency( ) fake_gh = tmp_path / "gh" fake_gh.write_text( - "#!/usr/bin/env bash\nprintf '%s' '{\"head\":{\"sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"state\":\"open\"}'\n", + "#!/usr/bin/env bash\nprintf '%s' '{\"number\":7,\"head\":{\"sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"state\":\"open\",\"base\":{\"ref\":\"develop\",\"sha\":\"cccccccccccccccccccccccccccccccccccccccc\",\"repo\":{\"full_name\":\"ContextualWisdomLab/example\"}}}'\n", encoding="utf-8", ) fake_gh.chmod(0o755) @@ -316,6 +316,9 @@ def test_noema_admission_retires_out_of_order_dispatch_before_concurrency( "TARGET_REPOSITORY": "ContextualWisdomLab/example", "PR_NUMBER": "7", "EXPECTED_HEAD_SHA": "a" * 40, + "EXPECTED_BASE_REF": "develop", + "EXPECTED_BASE_SHA": "c" * 40, + "LEGACY_BASE_REF": "", }, capture_output=True, text=True, diff --git a/tests/test_noema_review_handoff.py b/tests/test_noema_review_handoff.py index 3a13a713cc..84cdb51dfb 100644 --- a/tests/test_noema_review_handoff.py +++ b/tests/test_noema_review_handoff.py @@ -1,4 +1,5 @@ import json +import os import subprocess import sys from pathlib import Path @@ -8,10 +9,103 @@ from scripts.ci import noema_review_gate as gate from scripts.ci import noema_review_handoff as handoff +from tests.test_required_workflow_queue_contract import workflow_text +from tests.test_opencode_workflow_shell_syntax import _extract_run_block HEAD = "a" * 40 OTHER_HEAD = "b" * 40 +BASE = "c" * 40 + + +def test_dispatch_targets_central_noema_receiver(): + calls = [] + handoff.dispatch_noema( + "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", base_sha=BASE, + runner=lambda args, stdin: calls.append((args, json.loads(stdin))), + ) + assert calls[0][0] == [ + "api", "-X", "POST", "repos/ContextualWisdomLab/.github/dispatches", "--input", "-", + ] + assert calls[0][1] == { + "event_type": "noema-review", + "client_payload": { + "target_repository": "ContextualWisdomLab/example", "pr_number": 7, + "pr_head_sha": HEAD, "pr_base_ref": "develop", "pr_base_sha": BASE, + }, + } + + +@pytest.mark.parametrize("override", [ + {"repo": "external/example"}, {"number": 0}, {"head_sha": "short"}, + {"base_sha": ""}, {"base_sha": "not-a-sha"}, + {"base_ref": ""}, {"base_ref": "bad..ref"}, +]) +def test_malformed_dispatch_identity_never_posts(override): + arguments = dict(repo="ContextualWisdomLab/example", number=7, + head_sha=HEAD, base_ref="develop", base_sha=BASE) + calls = [] + with pytest.raises(ValueError, match="exact pull request identity"): + handoff.dispatch_noema(**(arguments | override), + runner=lambda *args: calls.append(args)) + assert calls == [] + + +@pytest.mark.parametrize("step_name", [ + "Admit only the exact live Noema head", + "Validate current pull request head", +]) +@pytest.mark.parametrize( + ("ref", "legacy", "base", "live_override", "admitted"), + [ + ("develop", "", BASE, {}, True), + ("", "develop", BASE, {}, True), + ("develop", "develop", BASE, {}, True), + ("develop", "main", BASE, {}, False), + ("", "", BASE, {}, False), + ("develop", "", "", {}, False), + ("develop", "", "not-a-sha", {}, False), + ("bad..ref", "", BASE, {}, False), + ("develop", "", BASE, {"base": {"ref": "main", "sha": BASE, "repo": {"full_name": "ContextualWisdomLab/example"}}}, False), + ("develop", "", BASE, {"base": {"ref": "develop", "sha": OTHER_HEAD, "repo": {"full_name": "ContextualWisdomLab/example"}}}, False), + ("develop", "", BASE, {"base": {"ref": "develop", "sha": BASE, "repo": {"full_name": "ContextualWisdomLab/other"}}}, False), + ("develop", "", BASE, {"number": 8}, False), + ("develop", "", BASE, {"head": {"sha": OTHER_HEAD}}, False), + ("develop", "", BASE, {"base": None}, False), + ], +) +def test_central_receiver_binds_live_base_and_head( + tmp_path, step_name, ref, legacy, base, live_override, admitted, +): + """Execute production admission shell; fake gh never performs external writes.""" + live = { + "number": 7, "state": "open", "head": {"sha": HEAD}, + "base": {"ref": "develop", "sha": BASE, "repo": {"full_name": "ContextualWisdomLab/example"}}, + **live_override, + } + fake = tmp_path / "gh" + fake.write_text( + '#!/bin/sh\n[ "$*" = "api repos/ContextualWisdomLab/example/pulls/7" ] || exit 91\nprintf "%s" "$FAKE_PR"\n', + encoding="utf-8", + ) + fake.chmod(0o755) + output = tmp_path / "output" + shell = _extract_run_block(workflow_text("noema-review.yml"), step_name) + result = subprocess.run( + ["bash", "-c", shell], check=False, capture_output=True, text=True, + env={**os.environ, "PATH": f"{tmp_path}{os.pathsep}{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), "FAKE_PR": json.dumps(live), + "TARGET_REPOSITORY": "ContextualWisdomLab/example", "PR_NUMBER": "7", + "EXPECTED_HEAD_SHA": HEAD, "EXPECTED_BASE_REF": ref, + "LEGACY_BASE_REF": legacy, "EXPECTED_BASE_SHA": base}, + ) + if step_name == "Admit only the exact live Noema head": + assert ("admitted=true" in output.read_text()) is admitted, (result.stdout, result.stderr) + else: + assert (result.returncode == 0) is admitted, (result.stdout, result.stderr) + if admitted: + assert result.returncode == 0, result.stderr def test_footer_marker_stays_synchronized_between_publisher_and_consumer(): @@ -110,6 +204,8 @@ def test_existing_noema_approval_avoids_duplicate_dispatch(capsys): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=2, interval_seconds=0, runner=fake, @@ -353,6 +449,8 @@ def test_stale_initial_head_never_reads_reviews_or_dispatches(capsys): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=2, interval_seconds=0, runner=fake, @@ -372,6 +470,8 @@ def test_missing_primary_approval_never_dispatches(capsys): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=2, interval_seconds=0, runner=fake, @@ -396,6 +496,8 @@ def test_dispatches_exact_head_and_waits_for_noema_approval(capsys): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=3, interval_seconds=0, runner=fake, @@ -411,6 +513,8 @@ def test_dispatches_exact_head_and_waits_for_noema_approval(capsys): "target_repository": "ContextualWisdomLab/example", "pr_number": 7, "pr_head_sha": HEAD, + "pr_base_ref": "develop", + "pr_base_sha": BASE, }, } ] @@ -429,6 +533,8 @@ def test_noema_changes_requested_is_terminal(capsys): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=2, interval_seconds=0, runner=fake, @@ -449,6 +555,8 @@ def test_head_change_stops_polling(capsys): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=2, interval_seconds=0, runner=fake, @@ -466,6 +574,8 @@ def test_missing_noema_verdict_times_out_closed(capsys): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=1, interval_seconds=0, runner=fake, @@ -500,6 +610,8 @@ def transient_runner(args, stdin=None): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=3, interval_seconds=2, runner=transient_runner, @@ -530,6 +642,8 @@ def transient_runner(args, stdin=None): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=3, interval_seconds=2, runner=transient_runner, @@ -554,6 +668,8 @@ def failing_runner(_args, _stdin=None): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=2, interval_seconds=2, runner=failing_runner, @@ -596,6 +712,8 @@ def transient_runner(args, stdin=None): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=3, interval_seconds=2, runner=transient_runner, @@ -626,6 +744,8 @@ def failing_dispatch_runner(args, stdin=None): "ContextualWisdomLab/example", 7, HEAD, + base_ref="develop", + base_sha=BASE, attempts=1, interval_seconds=0, runner=failing_dispatch_runner, @@ -704,6 +824,8 @@ def test_parse_args_accepts_valid_handoff(): "7", "--head-sha", HEAD, + "--base-ref", "develop", + "--base-sha", BASE, "--attempts", "3", "--interval-seconds", @@ -724,6 +846,8 @@ def test_parse_args_accepts_valid_handoff(): ("--repo", "external/example", "ContextualWisdomLab repository"), ("--pr-number", "0", "pr-number must be positive"), ("--head-sha", "short", "40-character Git SHA"), + ("--base-sha", "short", "40-character Git SHA"), + ("--base-ref", "", "base-ref must not be empty"), ("--attempts", "0", "attempts must be positive"), ("--interval-seconds", "-1", "interval-seconds must be non-negative"), ], @@ -736,6 +860,8 @@ def test_parse_args_rejects_unsafe_inputs(argument, value, message, capsys): "7", "--head-sha", HEAD, + "--base-ref", "develop", + "--base-sha", BASE, "--attempts", "3", "--interval-seconds", @@ -752,11 +878,13 @@ def test_parse_args_rejects_unsafe_inputs(argument, value, message, capsys): def test_main_passes_validated_arguments_to_handoff(monkeypatch): observed = {} - def fake_handoff(repo, number, head_sha, *, attempts, interval_seconds): + def fake_handoff(repo, number, head_sha, *, base_ref, base_sha, attempts, interval_seconds): observed.update( repo=repo, number=number, head_sha=head_sha, + base_ref=base_ref, + base_sha=base_sha, attempts=attempts, interval_seconds=interval_seconds, ) @@ -772,6 +900,8 @@ def fake_handoff(repo, number, head_sha, *, attempts, interval_seconds): "7", "--head-sha", HEAD, + "--base-ref", "develop", + "--base-sha", BASE, "--attempts", "4", "--interval-seconds", @@ -784,6 +914,8 @@ def fake_handoff(repo, number, head_sha, *, attempts, interval_seconds): "repo": "ContextualWisdomLab/example", "number": 7, "head_sha": HEAD, + "base_ref": "develop", + "base_sha": BASE, "attempts": 4, "interval_seconds": 1.25, } diff --git a/tests/test_pr_review_autofix_nvidia_nim_contract.py b/tests/test_pr_review_autofix_nvidia_nim_contract.py index 2e733ac9e9..69303049e9 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 = "d86497b3f43bebbabbb4f504eb5132cdf3b7b293" +REVIEW_DISPATCH_BLOB_SHA = "50224888f2d7e346afeeb282098a3505e21185e0" def _workflow_text(path: Path) -> str: diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index 19fe6b0f7f..0e22fc8348 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -1434,6 +1434,10 @@ def test_opencode_dispatch_hands_approved_head_to_noema_before_merge() -> None: assert '--repo "$GH_REPOSITORY"' in handoff assert '--pr-number "$PR_NUMBER"' in handoff assert '--head-sha "$PR_HEAD_SHA"' in handoff + assert '--base-ref "$PR_BASE_REF"' in handoff + assert '--base-sha "$PR_BASE_SHA"' in handoff + assert "PR_BASE_REF: ${{ needs.validate-pr-metadata.outputs.base_ref }}" in handoff + assert "PR_BASE_SHA: ${{ needs.validate-pr-metadata.outputs.base_sha }}" in handoff assert "--attempts 90" in handoff assert "--interval-seconds 10" in handoff for sealed_env in (