diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index db06b7c4ba..c21c8446df 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -171,10 +171,12 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LANGUAGE: ${{ matrix.language }} RUN_ATTEMPT: ${{ github.run_attempt }} + REQUIRED_RUN_ID: ${{ github.run_id }} run: | 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." @@ -188,6 +190,14 @@ jobs: 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 statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${LANGUAGE}" ' @@ -208,6 +218,36 @@ jobs: exit 0 ;; esac + + 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 + if [ "$RUN_ATTEMPT" != "1" ]; then echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict." exit 1 @@ -251,7 +291,6 @@ jobs: always() && github.event.action != 'closed' && github.event.pull_request.state != 'closed' - && github.run_attempt == 1 && needs.detect-languages.result == 'success' && needs.detect-languages.outputs.code == 'true' runs-on: ubuntu-24.04 @@ -277,6 +316,9 @@ jobs: 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." @@ -294,6 +336,10 @@ jobs: 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 include_json="$(printf '%s' "$MATRIX" | jq -c '.include // empty' 2>/dev/null || true)" if [ -z "$include_json" ] || @@ -385,10 +431,10 @@ jobs: jq -cn \ --arg target_repository "$TARGET_REPOSITORY" \ --arg pr_number "$PR_NUMBER" \ - --arg pr_base_ref "$PR_BASE_REF" \ - --arg pr_base_sha "$PR_BASE_SHA" \ - --arg pr_head_ref "$PR_HEAD_REF" \ - --arg pr_head_sha "$PR_HEAD_SHA" \ + --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" \ diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml index 521ceeb167..1516b541a0 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -16,7 +16,9 @@ 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_head.sha || 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: @@ -142,8 +144,12 @@ jobs: 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_HEAD_ENVELOPE: ${{ toJSON(github.event.client_payload.pr_head) }} + SUPPLIED_HEAD_SCHEMA: ${{ github.event.client_payload.pr_head.schema || '' }} + SUPPLIED_LEGACY_HEAD_REF: ${{ github.event.client_payload.pr_head_ref || '' }} + SUPPLIED_LEGACY_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }} + SUPPLIED_HEAD_REF: ${{ github.event.client_payload.pr_head.ref || github.event.client_payload.pr_head_ref || '' }} + SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_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) }} @@ -175,6 +181,33 @@ jobs: fi printf 'Authorized repository_dispatch actor=%s sender=%s target=%s.\n' "$DISPATCH_ACTOR" "$DISPATCH_SENDER" "$TARGET_REPOSITORY" + if [ "$SUPPLIED_HEAD_ENVELOPE" != "null" ]; then + head_envelope_json="$(printf '%s' "$SUPPLIED_HEAD_ENVELOPE" | jq -c 'select(type == "object")' 2>/dev/null || true)" + head_schema_type="$(printf '%s' "$head_envelope_json" | jq -r '.schema | type' 2>/dev/null || true)" + head_schema="$(printf '%s' "$head_envelope_json" | jq -r '.schema // empty' 2>/dev/null || true)" + if [ "$head_schema_type" = "null" ]; then + printf '::error::repository_dispatch supplied unsupported pr_head schema=.\n' + exit 1 + fi + if [ "$head_schema_type" != "string" ]; then + printf '::error::repository_dispatch supplied malformed pr_head envelope; expected string schema="1" and non-empty ref/sha strings.\n' + exit 1 + fi + if [ "$head_schema" != "1" ]; then + printf '::error::repository_dispatch supplied unsupported pr_head schema=%s.\n' "$head_schema" + exit 1 + fi + if [ "$(printf '%s' "$head_envelope_json" | jq '(.ref | type == "string" and length > 0) and (.sha | type == "string" and test("^[0-9a-f]{40}$"))')" != "true" ]; then + printf '::error::repository_dispatch supplied malformed pr_head envelope; expected string schema="1" and non-empty ref/sha strings.\n' + exit 1 + fi + SUPPLIED_HEAD_REF="$(printf '%s' "$head_envelope_json" | jq -r '.ref')" + SUPPLIED_HEAD_SHA="$(printf '%s' "$head_envelope_json" | jq -r '.sha')" + else + SUPPLIED_HEAD_REF="$SUPPLIED_LEGACY_HEAD_REF" + SUPPLIED_HEAD_SHA="$SUPPLIED_LEGACY_HEAD_SHA" + fi + 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:-}" @@ -503,6 +536,11 @@ jobs: 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index df6ee0c9b9..2ffef498c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### CodeQL dispatch validates the original versioned head envelope + +- `codeql-scan-dispatch.yml` now parses the original `pr_head` JSON and accepts a present envelope only when it is an object with string schema `"1"`, a non-empty string ref, and a 40-character lowercase hexadecimal SHA. Numeric schemas and incomplete envelopes fail closed instead of borrowing legacy fields. The legacy scalar fallback is used only when `pr_head` is absent, and executable regressions prove the nested tuple wins even when stale legacy values are also present. Refs #2043, #2040. + ### 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. @@ -68,6 +72,13 @@ - 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] +- Accept a versioned `pr_head` object (`schema`, `ref`, and `sha`) in the + central CodeQL scan-dispatch handler while retaining the legacy + `pr_head_ref`/`pr_head_sha` fallback for already-queued callers. This is the + backward-compatible handler prerequisite for moving the producer below + GitHub's ten-top-level-property `repository_dispatch.client_payload` limit; + missing or unknown envelope versions fail closed before pull-request metadata + is used. - 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 diff --git a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md index 5a11894767..507dd3404f 100644 --- a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md +++ b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md @@ -210,6 +210,41 @@ 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. +#### 2026-09-08 amendment: version the head tuple to stay within GitHub's dispatch limit + +**Status: Proposed.** Exact-head CodeQL run +[`34214980549`](https://github.com/ContextualWisdomLab/.github/actions/runs/34214980549), +coordinator job +[`102028015000`](https://github.com/ContextualWisdomLab/.github/actions/runs/34214980549/job/102028015000), +failed before creating a handler run because GitHub rejected the producer's +11-property `client_payload` with HTTP 422: no more than ten top-level +properties are accepted. The extra properties are not disposable: live base, +head, producer revision, required-run, job, and matrix identities are all +security or exact-evidence bindings. + +The selected migration groups only the head tuple into one versioned object: +`pr_head: {schema: "1", ref: , sha: }`. The handler lands first and +accepts this object while retaining the two legacy scalar fields for in-flight +dispatches. When the nested object is present, the handler parses the original +JSON and requires an object containing string schema `"1"`, a non-empty string +ref, and a 40-character lowercase hexadecimal SHA. It rejects numeric schemas, +missing fields, malformed objects, and unknown versions without consulting the +legacy fields; only an absent object activates the scalar fallback. After that +compatibility foundation is merged and proven, the #1902 +producer may replace `pr_head_ref` plus `pr_head_sha` with `pr_head`, reducing +its top-level count to ten without weakening live-PR or exact-head checks. + +Alternatives were rejected as follows: deleting an identity field loses a +validation invariant; compacting unrelated fields creates an unnecessarily +large schema transition; and changing the producer before the default-branch +handler understands the envelope makes the repairing PR unable to produce its +own exact-head hosted evidence. The legacy fallback is temporary compatibility, +not authority to accept conflicting shapes: producer tests must emit only one +shape, and a later cleanup may remove the scalars after no live caller remains. +Executable contracts deliberately make the nested tuple match the live PR while +supplying different valid legacy values, so a regression to fallback preference +cannot pass unnoticed. + ## Scope decision: `analyze-merge` is dropped, not migrated `analyze-merge` ("CodeQL merge preview") is confirmed, per PR #1766's own diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cae019f9a..f1d000eea6 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -3039,6 +3039,38 @@ No second repository may be changed until the central run reaches an explicit su 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. + +### Proposed control-plane repair: bounded CodeQL dispatch head envelope — 2026-09-08 + +**Observed gap.** `.github` PR #1902 exact head `e0924260c2105b49e8840701ce8509d765125b0f` +reached the coordinator in run +[`34214980549`](https://github.com/ContextualWisdomLab/.github/actions/runs/34214980549), +job +[`102028015000`](https://github.com/ContextualWisdomLab/.github/actions/runs/34214980549/job/102028015000), +but GitHub rejected its `repository_dispatch.client_payload` with HTTP 422 +because it supplied 11 top-level properties and the API permits no more than +ten. No scan handler or SARIF evidence was created, so this is a producer/API +contract failure rather than a CodeQL analysis failure. + +**Boundary and action.** `.github` remains the owner of both the required +producer and native handler contract. Land the backward-compatible handler +foundation first: accept `pr_head: {schema: "1", ref, sha}`, prefer it over the +legacy scalar fields, reject missing or unknown nested-object versions, and +keep legacy fallback only for already-queued calls. Then repair #1902 to replace the two head scalars +with that one object and regenerate combined exact-head hosted evidence. Do not +drop base/head/run/job/matrix/provenance fields, copy handler source, or treat a +predecessor run as GREEN. After migration, remove the legacy bridge only after +an inventory proves no live caller remains. + +**Current-source repair.** Review of #2043 found that validating only the +interpolated schema string allowed JSON number `1` and let an incomplete nested +object borrow legacy ref/SHA values. The handler now validates the original JSON +object and uses legacy scalars only when that object is absent. RED coverage +pins numeric schema rejection, missing ref/SHA rejection, legacy-only success, +and nested precedence over deliberately stale legacy values. + +**Status:** Proposed; strict handler RED/GREEN contract prepared from protected main, with hosted exact-head evidence still required. + ## 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 diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 2d11ca0141..dc67eef258 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -87,8 +87,28 @@ def test_codeql_pr_shards_do_not_dispatch_and_coordinator_sends_the_full_matrix_ assert "needs: [detect-languages, analyze-head]" in coordinator assert "always()" in coordinator.split("\n runs-on:", 1)[0] assert "github.event.action != 'closed'" in coordinator.split("\n runs-on:", 1)[0] - assert "github.run_attempt == 1" in coordinator.split("\n runs-on:", 1)[0] + coordinator_if = coordinator.split("\n runs-on:", 1)[0] + assert "github.run_attempt == 1" not in coordinator_if assert coordinator.count("repos/ContextualWisdomLab/.github/dispatches") == 1 + + +def test_codeql_coordinator_dispatches_later_attempts_when_no_terminal_verdict() -> None: + """A rerun must still POST codeql-scan if attempt 1 never dispatched. + + Live ContextualWisdomLab/.github#2028 run 34175742278 was attempt 2. + ``github.run_attempt == 1`` skipped Dispatch current-head, so no + codeql-scan-dispatch.yml run existed and compatibility stayed pending. + The coordinator script already skips when every language has a terminal + opencode-agent verdict, so later attempts are safe. + """ + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + coordinator_if = workflow.split(" dispatch-current-head:\n", 1)[1].split( + "\n runs-on:", 1 + )[0] + coordinator = workflow.split(" dispatch-current-head:\n", 1)[1] + + assert "github.run_attempt == 1" not in coordinator_if + assert "All detected CodeQL languages already have authenticated terminal verdicts" in coordinator assert 'event_type:"codeql-scan"' in coordinator assert "required_jobs:$required_jobs" in coordinator assert "required_run_id:$required_run_id" in coordinator @@ -130,10 +150,47 @@ def test_codeql_pr_dispatch_and_release_run_blocks_are_valid_bash() -> None: DISPATCH_STEP_NAME = "Read current-head CodeQL dispatch verdict" VERDICT_STEP_NAME = "Release runner or enforce current-head CodeQL verdict" COORDINATOR_STEP_NAME = "Dispatch current-head CodeQL scan" +_TEST_HEAD_SHA = "b" * 40 +_TEST_BASE_SHA = "a" * 40 +_TEST_REQUIRED_RUN_ID = "42" + + +def _dispatch_scan_title( + *, + head_sha: str = _TEST_HEAD_SHA, + base_sha: str = _TEST_BASE_SHA, + required_run_id: str = _TEST_REQUIRED_RUN_ID, +) -> str: + """Return the immutable CodeQL dispatch run-name for one required shard.""" + return ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + f"{head_sha}/{base_sha}/{required_run_id}" + ) + + +def _completed_dispatch_run( + *, + title: str, + run_id: int = 34173910106, +) -> dict: + """Return one completed central CodeQL dispatch workflow-run fixture.""" + return { + "id": run_id, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "status": "completed", + "display_title": title, + "name": title, + } def _run_verdict_read( - tmp_path: Path, statuses: list[dict] + tmp_path: Path, + statuses: list[dict], + *, + dispatch_runs: dict | list[dict] | None = None, + dispatch_jobs: dict | list[dict] | None = None, + run_attempt: str = "2", ) -> tuple[subprocess.CompletedProcess[str], subprocess.CompletedProcess[str]]: """Execute the real one-shot status read and verdict enforcement blocks.""" bash = shutil.which("bash") @@ -144,8 +201,12 @@ def _run_verdict_read( dispatch_script = _extract_run_block(workflow_text, DISPATCH_STEP_NAME) verdict_script = _extract_run_block(workflow_text, VERDICT_STEP_NAME) - head_sha = "b" * 40 - live_pr = {"head": {"sha": head_sha}, "state": "open"} + head_sha = _TEST_HEAD_SHA + live_pr = { + "head": {"sha": head_sha}, + "base": {"sha": _TEST_BASE_SHA}, + "state": "open", + } fake_bin = tmp_path / "bin" fake_bin.mkdir() @@ -154,9 +215,12 @@ def _run_verdict_read( "#!/usr/bin/env bash\n" "set -euo pipefail\n" 'test "$1" = api\n' - 'case "$2" in\n' + 'endpoint="${@: -1}"\n' + 'case "$endpoint" in\n' " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" " */statuses) printf '%s\\n' \"$FAKE_STATUSES_JSON\" ;;\n" + " */codeql-scan-dispatch.yml/runs*) printf '%s\\n' \"$FAKE_DISPATCH_RUNS_JSON\" ;;\n" + " */actions/runs/*/jobs*) printf '%s\\n' \"$FAKE_DISPATCH_JOBS_JSON\" ;;\n" " *) exit 1 ;;\n" "esac\n", encoding="utf-8", @@ -169,6 +233,16 @@ def _run_verdict_read( "PATH": f"{fake_bin}:{os.environ['PATH']}", "FAKE_PULL_JSON": json.dumps(live_pr), "FAKE_STATUSES_JSON": json.dumps(statuses), + "FAKE_DISPATCH_RUNS_JSON": json.dumps( + dispatch_runs + if isinstance(dispatch_runs, list) + else [dispatch_runs if dispatch_runs is not None else {"workflow_runs": []}] + ), + "FAKE_DISPATCH_JOBS_JSON": json.dumps( + dispatch_jobs + if isinstance(dispatch_jobs, list) + else [dispatch_jobs if dispatch_jobs is not None else {"jobs": []}] + ), "GH_TOKEN": "fake-token", "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", "PR_NUMBER": "42", @@ -176,10 +250,10 @@ def _run_verdict_read( "LANGUAGE": "python", "BUILD_MODE": "none", "BASE_REF": "main", - "BASE_SHA": "a" * 40, + "BASE_SHA": _TEST_BASE_SHA, "HEAD_REF": "feature", - "RUN_ATTEMPT": "2", - "REQUIRED_RUN_ID": "42", + "RUN_ATTEMPT": run_attempt, + "REQUIRED_RUN_ID": _TEST_REQUIRED_RUN_ID, "REQUIRED_JOB_ID": "43", "GITHUB_OUTPUT": str(output), } @@ -187,9 +261,16 @@ def _run_verdict_read( [bash], input=dispatch_script, text=True, capture_output=True, check=False, env=dispatch_env, timeout=60, ) - output_values = dict( - line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines() - ) + output_values = {} + if output.exists(): + output_values = dict( + line.split("=", 1) for line in output.read_text(encoding="utf-8").splitlines() + if "=" in line + ) + if "verdict" not in output_values: + return dispatch_result, subprocess.CompletedProcess( + args=[bash], returncode=1, stdout="", stderr="" + ) verdict_env = { **os.environ, "LANGUAGE": "python", @@ -248,6 +329,145 @@ def test_codeql_pr_one_shot_read_accepts_the_opencode_agent_creator(tmp_path: Pa assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout +def test_codeql_pr_one_shot_read_accepts_completed_dispatch_scan_job_when_status_unpublishable( + tmp_path: Path, +) -> None: + """A completed dispatch scan job is terminal evidence when statuses:write 403s. + + Live 2026-09-08 naruon#1596 dispatch run 34173910106 scanned clean, then + POST /statuses returned HTTP 403 for opencode-agent (statuses:read only) + and github.token (cross-repo). The required shard must consume that + completed scan job instead of staying fail-closed on a missing status. + """ + head_sha = _TEST_HEAD_SHA + title = _dispatch_scan_title(head_sha=head_sha) + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + dispatch_runs={"workflow_runs": [_completed_dispatch_run(title=title)]}, + dispatch_jobs={ + "jobs": [ + { + "name": "CodeQL dispatch scan (python)", + "conclusion": "success", + } + ] + }, + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + dispatch_result.stdout + assert verdict_result.returncode == 0, verdict_result.stderr + verdict_result.stdout + assert "completed CodeQL dispatch scan job for python: success" in dispatch_result.stdout + assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout + + +def test_codeql_pr_finds_completed_dispatch_scan_beyond_first_results_page( + tmp_path: Path, +) -> None: + """The exact completed dispatch remains discoverable on later API pages.""" + head_sha = _TEST_HEAD_SHA + expected_title = _dispatch_scan_title(head_sha=head_sha) + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + dispatch_runs=[ + {"workflow_runs": []}, + {"workflow_runs": [_completed_dispatch_run(title=expected_title)]}, + ], + dispatch_jobs=[ + {"jobs": []}, + { + "jobs": [ + { + "name": "CodeQL dispatch scan (python)", + "conclusion": "success", + } + ] + }, + ], + ) + + assert dispatch_result.returncode == 0, dispatch_result.stderr + dispatch_result.stdout + assert verdict_result.returncode == 0, verdict_result.stderr + verdict_result.stdout + assert "completed CodeQL dispatch scan job for python: success" in dispatch_result.stdout + + +def test_codeql_pr_rejects_completed_dispatch_scan_from_a_stale_base( + tmp_path: Path, +) -> None: + """Same head and language after a base retarget must not reuse the prior scan. + + A PR can keep its head SHA while the base moves. The native handler already + binds receipts to the live base SHA; the required shard must not accept a + completed dispatch whose run-name still names the predecessor base. + """ + stale_title = _dispatch_scan_title(base_sha="c" * 40) + dispatch_result, _verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + dispatch_runs={"workflow_runs": [_completed_dispatch_run(title=stale_title)]}, + dispatch_jobs={ + "jobs": [ + { + "name": "CodeQL dispatch scan (python)", + "conclusion": "success", + } + ] + }, + ) + + assert dispatch_result.returncode == 1, dispatch_result.stderr + dispatch_result.stdout + assert "without an authenticated terminal verdict" in dispatch_result.stdout + assert "completed CodeQL dispatch scan job for python: success" not in dispatch_result.stdout + + +def test_codeql_pr_rejects_completed_dispatch_scan_from_a_different_required_run( + tmp_path: Path, +) -> None: + """A same-PR/head/language scan for another required run cannot wake this shard. + + Language plus repository/PR/head is not enough: each waiting required job + lives in one required-workflow run. Binding required_run_id in the + dispatch run-name, together with the language job name, is the job + identity the shard can observe without reading client_payload. + """ + other_run_title = _dispatch_scan_title(required_run_id="99") + dispatch_result, _verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + dispatch_runs={ + "workflow_runs": [_completed_dispatch_run(title=other_run_title)] + }, + dispatch_jobs={ + "jobs": [ + { + "name": "CodeQL dispatch scan (python)", + "conclusion": "success", + } + ] + }, + ) + + assert dispatch_result.returncode == 1, dispatch_result.stderr + dispatch_result.stdout + assert "without an authenticated terminal verdict" in dispatch_result.stdout + assert "completed CodeQL dispatch scan job for python: success" not in dispatch_result.stdout + + +def test_codeql_pr_fallback_binds_live_base_and_required_run_identity() -> None: + """The required shard looks up the public dispatch run by immutable identity.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + shard = workflow.split(" analyze-head:\n", 1)[1].split( + " dispatch-current-head:\n", 1 + )[0] + + assert "REQUIRED_RUN_ID: ${{ github.run_id }}" in shard + assert 'live_base="$(printf' in shard + assert ( + 'expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}' + '@${PR_HEAD_SHA}/${live_base}/${REQUIRED_RUN_ID}"' + ) in shard + assert "Could not validate live pull request base SHA before CodeQL verdict read." in shard + + def test_codeql_action_steps_use_one_version_per_workflow() -> None: """Prevent CodeQL init/analyze version splits from failing the scheduled scan.""" workflow = (REPO_ROOT / ".github/workflows/scheduled-security-scan.yml").read_text( @@ -319,9 +539,12 @@ def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( ' printf \'%s\\n\' "$4" >>"$FAKE_POST_LOG"\n' " exit 0\n" "fi\n" - 'case "$2" in\n' + 'endpoint="${@: -1}"\n' + 'case "$endpoint" in\n' " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" " */statuses) printf '%s\\n' \"$FAKE_STATUSES_JSON\" ;;\n" + " */codeql-scan-dispatch.yml/runs*) printf '%s\\n' \"$FAKE_DISPATCH_RUNS_JSON\" ;;\n" + " */actions/runs/*/jobs*) printf '%s\\n' \"$FAKE_DISPATCH_JOBS_JSON\" ;;\n" " *) exit 1 ;;\n" "esac\n", encoding="utf-8", @@ -331,8 +554,16 @@ def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( env = { **os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", - "FAKE_PULL_JSON": json.dumps({"head": {"sha": head_sha}, "state": "open"}), + "FAKE_PULL_JSON": json.dumps( + { + "head": {"sha": head_sha}, + "base": {"sha": _TEST_BASE_SHA}, + "state": "open", + } + ), "FAKE_STATUSES_JSON": json.dumps([]), + "FAKE_DISPATCH_RUNS_JSON": json.dumps([{"workflow_runs": []}]), + "FAKE_DISPATCH_JOBS_JSON": json.dumps([{"jobs": []}]), "FAKE_POST_LOG": str(post_log), "GH_TOKEN": "fake-token", "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", @@ -593,6 +824,32 @@ def test_codeql_coordinator_fails_closed_when_a_shard_job_id_is_missing( assert not post_log.exists() +def test_codeql_coordinator_dispatches_the_live_base_after_a_same_head_retarget( + tmp_path: Path, +) -> None: + """A retargeted PR must dispatch against the live base, not the event snapshot.""" + live_base = "c" * 40 + result, post_log, post_body = _run_coordinator( + tmp_path, + pull={ + "state": "open", + "head": {"sha": "b" * 40, "ref": "feature"}, + "base": {"sha": live_base, "ref": "release"}, + }, + env_overrides={"PR_BASE_SHA": "a" * 40, "PR_BASE_REF": "main"}, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/.github/dispatches" + ] + client = json.loads(post_body.read_text(encoding="utf-8"))["client_payload"] + assert client["pr_base_sha"] == live_base + assert client["pr_base_ref"] == "release" + assert client["pr_head_sha"] == "b" * 40 + assert client["required_run_id"] == "99" + + def test_codeql_coordinator_does_not_dispatch_a_closed_or_stale_pull_request( tmp_path: Path, ) -> None: diff --git a/tests/test_codeql_scan_dispatch_workflow_contract.py b/tests/test_codeql_scan_dispatch_workflow_contract.py index dea1326494..e676b3ebd4 100644 --- a/tests/test_codeql_scan_dispatch_workflow_contract.py +++ b/tests/test_codeql_scan_dispatch_workflow_contract.py @@ -17,6 +17,8 @@ import sys from pathlib import Path +import pytest + from scripts.ci import audit_central_required_workflows as ruleset_audit from tests.test_opencode_workflow_shell_syntax import _extract_run_block from tests.test_required_workflow_queue_contract import ( @@ -155,6 +157,10 @@ def _run_validate_step(tmp_path: Path, env_overrides: dict[str, str], pull_reque "PR_NUMBER": "42", "SUPPLIED_BASE_REF": "main", "SUPPLIED_BASE_SHA": "a" * 40, + "SUPPLIED_HEAD_ENVELOPE": "null", + "SUPPLIED_HEAD_SCHEMA": "", + "SUPPLIED_LEGACY_HEAD_REF": "feature", + "SUPPLIED_LEGACY_HEAD_SHA": "b" * 40, "SUPPLIED_HEAD_REF": "feature", "SUPPLIED_HEAD_SHA": "b" * 40, "SUPPLIED_MATRIX": json.dumps([{"language": "python", "build-mode": "none"}]), @@ -194,6 +200,104 @@ def test_codeql_scan_dispatch_validate_step_accepts_matching_live_metadata(tmp_p assert "required_language=" not in output_text +def test_codeql_scan_dispatch_validate_step_rejects_unknown_head_schema(tmp_path): + """Unknown nested-head schema versions fail before metadata can be trusted.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_HEAD_ENVELOPE": json.dumps( + {"schema": "2", "ref": "feature", "sha": "b" * 40} + ), + "SUPPLIED_HEAD_SCHEMA": "2", + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "unsupported pr_head schema=2" in result.stdout + + +def test_codeql_scan_dispatch_validate_step_accepts_versioned_head_envelope(tmp_path): + """Schema-one nested head metadata reaches the live validation success path.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_HEAD_ENVELOPE": json.dumps( + {"schema": "1", "ref": "feature", "sha": "b" * 40} + ), + "SUPPLIED_HEAD_SCHEMA": "1", + "SUPPLIED_LEGACY_HEAD_REF": "stale-feature", + "SUPPLIED_LEGACY_HEAD_SHA": "c" * 40, + "SUPPLIED_HEAD_REF": "stale-feature", + "SUPPLIED_HEAD_SHA": "c" * 40, + }, + _matching_pull_request(), + ) + + assert result.returncode == 0 + assert ( + "Validated current live metadata for ContextualWisdomLab/naruon#42: base=main/" + in result.stdout + ) + assert "head=feature/" in result.stdout + + +def test_codeql_scan_dispatch_validate_step_rejects_numeric_head_schema(tmp_path): + """JSON number 1 cannot impersonate the version string in the contract.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_HEAD_ENVELOPE": json.dumps( + {"schema": 1, "ref": "feature", "sha": "b" * 40} + ), + "SUPPLIED_HEAD_SCHEMA": "1", + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "malformed pr_head envelope" in result.stdout + + +@pytest.mark.parametrize("missing_field", ["ref", "sha"]) +def test_codeql_scan_dispatch_validate_step_rejects_incomplete_head_envelope( + tmp_path, missing_field +): + """A present envelope cannot borrow a required value from legacy fields.""" + envelope = {"schema": "1", "ref": "feature", "sha": "b" * 40} + del envelope[missing_field] + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_HEAD_ENVELOPE": json.dumps(envelope), + "SUPPLIED_HEAD_SCHEMA": "1", + "SUPPLIED_LEGACY_HEAD_REF": "feature", + "SUPPLIED_LEGACY_HEAD_SHA": "b" * 40, + "SUPPLIED_HEAD_REF": "feature", + "SUPPLIED_HEAD_SHA": "b" * 40, + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "malformed pr_head envelope" in result.stdout + + +def test_codeql_scan_dispatch_validate_step_rejects_unversioned_head_envelope(tmp_path): + """A nested head tuple without its schema version fails closed.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_HEAD_ENVELOPE": json.dumps({"ref": "feature", "sha": "b" * 40}), + "SUPPLIED_HEAD_SCHEMA": "", + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "unsupported pr_head schema=" in result.stdout + + def test_codeql_scan_dispatch_validate_step_rejects_actor_mismatch(tmp_path): """A dispatch from an unauthorized actor is rejected before any live PR read.""" result = _run_validate_step(tmp_path, {"DISPATCH_ACTOR": "someone-else"}, _matching_pull_request()) @@ -505,6 +609,73 @@ def test_codeql_scan_dispatch_is_not_in_the_required_workflow_ruleset_scope(): assert ".github/workflows/codeql-scan-dispatch.yml" not in required_paths +def test_codeql_scan_dispatch_run_name_binds_base_and_required_run() -> None: + """Public run identity includes base SHA and required run id without changing concurrency. + + The required shard cannot read client_payload. Encoding those fields in + run-name lets it reject a same-head retarget or a different waiting + required run. The #2008/#2009 group stays repository+PR so a newer HEAD + of the same pull request still cancels its predecessor. + """ + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + header = workflow.split("\non:", 1)[0] + group_value = workflow_level_concurrency_group(workflow) + + assert "github.event.client_payload.pr_head_sha" in header + assert "github.event.client_payload.pr_base_sha" in header + assert "github.event.client_payload.required_run_id" in header + assert "github.event.client_payload.pr_base_sha" not in group_value + assert "github.event.client_payload.required_run_id" not in group_value + assert "github.event.client_payload.target_repository" in group_value + assert "github.event.client_payload.pr_number" in group_value + + +def test_codeql_scan_dispatch_accepts_versioned_head_envelope_with_legacy_fallback() -> None: + """The handler accepts the bounded head envelope without breaking queued legacy runs.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + header = workflow.split("\non:", 1)[0] + validate = workflow.split( + " - name: Bind workflow inputs to live organization pull request metadata\n", + 1, + )[1].split("\n run: |", 1)[0] + + assert ( + "github.event.client_payload.pr_head.sha || " + "github.event.client_payload.pr_head_sha || github.sha" + ) in header + assert ( + "SUPPLIED_HEAD_SCHEMA: ${{ github.event.client_payload.pr_head.schema || '' }}" + in validate + ) + assert ( + "SUPPLIED_HEAD_REF: ${{ github.event.client_payload.pr_head.ref || " + "github.event.client_payload.pr_head_ref || '' }}" + ) in validate + assert ( + "SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head.sha || " + "github.event.client_payload.pr_head_sha || '' }}" + ) in validate + assert 'unsupported pr_head schema' in workflow + + +def test_dispatch_publish_keeps_successful_scan_when_status_write_is_denied() -> None: + """A clean SARIF gate must not fail the handler solely because POST /statuses 403s. + + opencode-agent is installed with statuses:read. Cross-repo github.token cannot + write naruon commit statuses. The completed scan job is the remaining evidence. + """ + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + publish = workflow.split(" - name: Publish CodeQL dispatch status\n", 1)[1].split( + "\n - name: Wake exact CodeQL required job\n", 1 + )[0] + + assert "GATE_OUTCOME" in publish + assert 'if [ "$GATE_OUTCOME" = "success" ]; then' in publish + assert "completed dispatch scan job remains the evidence" in publish + assert "continue-on-error:" not in publish + assert "cancel-in-progress: true" not in publish + + def test_dispatch_wakes_only_the_exact_failed_codeql_job() -> None: workflow = WORKFLOW_PATH.read_text(encoding="utf-8") wake = workflow.split(" - name: Wake exact CodeQL required job\n", 1)[1].split(