diff --git a/.github/workflows/agent-review-runtime-quality-ci.yml b/.github/workflows/agent-review-runtime-quality-ci.yml index 3680da8778..4c0636aca3 100644 --- a/.github/workflows/agent-review-runtime-quality-ci.yml +++ b/.github/workflows/agent-review-runtime-quality-ci.yml @@ -2,7 +2,7 @@ name: Agent Review Runtime Quality CI on: pull_request: - branches: [main] + # Scan every PR base ref, including stacked feature branches. paths: - ".github/workflows/agent-review-runtime-quality-ci.yml" - ".github/workflows/noema-review.yml" diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index c21c8446df..b391a0c995 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -6,8 +6,9 @@ # 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: +# handler publishes a base-bound codeql-dispatch// +# receipt and settles the exact failed language jobs. On rerun each shard +# reads only its authenticated current-base terminal status. 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. @@ -63,7 +64,34 @@ jobs: outputs: matrix: ${{ steps.detect.outputs.matrix }} code: ${{ steps.scope.outputs.code }} + base_sha: ${{ steps.capture-base.outputs.base_sha }} steps: + - name: Capture CodeQL attempt base + id: capture-base + env: + GH_TOKEN: ${{ github.token }} + 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_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_state="$(jq -r '.state // empty' <<<"$live_pr")" + live_head="$(jq -r '.head.sha // empty' <<<"$live_pr")" + live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$live_pr")" + live_base_ref="$(jq -r '.base.ref // empty' <<<"$live_pr")" + live_base_sha="$(jq -r '.base.sha // empty' <<<"$live_pr")" + if [ "$live_state" != "open" ] || + [ "${live_head,,}" != "${PR_HEAD_SHA,,}" ] || + [ "$live_base_repository" != "$TARGET_REPOSITORY" ] || + [ "$live_base_ref" != "$PR_BASE_REF" ] || + ! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::CodeQL attempt base capture rejected stale or malformed live PR metadata." + exit 1 + fi + echo "base_sha=${live_base_sha,,}" >>"$GITHUB_OUTPUT" + - name: Checkout PR head uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -150,6 +178,7 @@ jobs: # closed PRs need no required check. runs-on: ubuntu-24.04 permissions: + actions: read contents: read id-token: write strategy: @@ -168,15 +197,18 @@ jobs: GH_TOKEN: ${{ github.token }} 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: ${{ needs.detect-languages.outputs.base_sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LANGUAGE: ${{ matrix.language }} RUN_ATTEMPT: ${{ github.run_attempt }} REQUIRED_RUN_ID: ${{ github.run_id }} + PRODUCER_SOURCE_SHA: ${{ github.workflow_sha }} 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." @@ -190,64 +222,225 @@ 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." + + live_base_repository="$(printf '%s' "$live_pr" | jq -r '.base.repo.full_name | select(type == "string")')" + live_base_ref="$(printf '%s' "$live_pr" | jq -r '.base.ref | select(type == "string")')" + live_base_sha="$(printf '%s' "$live_pr" | jq -r '.base.sha | select(type == "string")')" + if [ "$live_base_repository" != "$TARGET_REPOSITORY" ] || + [ -z "$live_base_ref" ] || [ -z "${PR_BASE_REF:-}" ] || + ! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "${PR_BASE_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "${PRODUCER_SOURCE_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]] || + [ "$live_base_ref" != "$PR_BASE_REF" ]; then + echo "::error::CodeQL live base metadata is missing, malformed, or targets a different base ref; terminal verdict reuse is blocked." exit 1 fi - if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then - echo "::error::CodeQL shard requires a canonical current run id." + if [ "${live_base_sha,,}" != "${PR_BASE_SHA,,}" ]; then + echo "::error::CodeQL live base advanced after the attempt base was captured; mixed-base evidence is blocked." 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}" ' - [ - .[] - | select(.context == $ctx) - | select( - (.creator.login // "" | ascii_downcase) as $creator - | $creator == "opencode-agent" or $creator == "opencode-agent[bot]" - ) - ] - | first // {} | .state // empty - ')" + handler_source_is_compatible() { + handler_source_sha="$1" + [[ "$handler_source_sha" =~ ^[0-9a-fA-F]{40}$ ]] || return 1 + protected_branch="$(gh api "repos/ContextualWisdomLab/.github/branches/main" 2>/dev/null)" || return 1 + protected_tip="$(printf '%s' "$protected_branch" | jq -r '.commit.sha // empty')" + if [ "$(printf '%s' "$protected_branch" | jq -r '.protected == true')" != "true" ] || + ! [[ "$protected_tip" =~ ^[0-9a-fA-F]{40}$ ]]; then + return 1 + fi + if [ "${handler_source_sha,,}" = "${protected_tip,,}" ]; then + return 0 + fi + source_compare="$(gh api "repos/ContextualWisdomLab/.github/compare/${handler_source_sha}...${protected_tip}" 2>/dev/null)" || return 1 + printf '%s' "$source_compare" | jq -e \ + --arg source "${handler_source_sha,,}" ' + .status == "ahead" + and .behind_by == 0 + and ((.base_commit.sha // "" | ascii_downcase) == $source) + and ((.merge_base_commit.sha // "" | ascii_downcase) == $source) + ' >/dev/null + } + + statuses="$(gh api --paginate --slurp "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses?per_page=100")" + trusted_receipt_evidence() { + receipt_context="codeql-dispatch/${LANGUAGE}/${PR_BASE_SHA}" + receipt_description="cwl1;h=${PR_HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${PRODUCER_SOURCE_SHA}" + receipt_evidence='[]' + while IFS= read -r candidate; do + creator="$(printf '%s' "$candidate" | jq -r '.creator.login // "" | ascii_downcase')" + state="$(printf '%s' "$candidate" | jq -r '.state // empty')" + case "$creator" in + opencode-agent|opencode-agent\[bot\]) ;; + github-actions\[bot\]) + # The default GITHUB_TOKEN can publish only to this workflow's + # own repository. Authenticate that narrow fallback through + # the exact protected repository_dispatch run, scan job, and + # preserved SARIF artifact instead of trusting creator or URL + # alone. + [ "${TARGET_REPOSITORY,,}" = "contextualwisdomlab/.github" ] || continue + [ "${GITHUB_REPOSITORY,,}" = "contextualwisdomlab/.github" ] || continue + ;; + *) continue ;; + esac + target_url="$(printf '%s' "$candidate" | jq -r '.target_url // empty')" + producer_run_id="${target_url##*/}" + [[ "$producer_run_id" =~ ^[1-9][0-9]*$ ]] || continue + if ! producer_run="$(gh api "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}" 2>/dev/null)"; then + continue + fi + handler_source_sha="$(printf '%s' "$producer_run" | jq -r '.head_sha // empty')" + handler_source_is_compatible "$handler_source_sha" || continue + expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${PR_HEAD_SHA}/${PR_BASE_SHA}/${REQUIRED_RUN_ID}/${PRODUCER_SOURCE_SHA}" + if ! printf '%s' "$producer_run" | jq -e \ + --argjson run_id "$producer_run_id" --arg title "$expected_title" \ + --arg source "$PRODUCER_SOURCE_SHA" ' + .id == $run_id + and .event == "repository_dispatch" + and .path == ".github/workflows/codeql-scan-dispatch.yml" + and .head_branch == "main" + and .display_title == $title + and .repository.full_name == "ContextualWisdomLab/.github" + and ((.actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + and ((.triggering_actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + ' >/dev/null; then + continue + fi + if ! producer_jobs="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/jobs?filter=latest&per_page=100" 2>/dev/null)"; then + continue + fi + if [ "$(printf '%s' "$producer_jobs" | jq '[.[]?.jobs[]? | select(.name == "validate-dispatch" and .status == "completed" and .conclusion == "success")] | length')" -ne 1 ]; then + continue + fi + expected_job="CodeQL dispatch scan (${LANGUAGE})" + job_attempt="$(printf '%s' "$producer_jobs" | jq -r \ + --arg name "$expected_job" --arg state "$state" ' + [ + .[]?.jobs[]? + | select(.name == $name and .status == "completed") + | select( + ($state == "success" and .conclusion == "success") + or ($state != "success" and .conclusion == "failure") + ) + | select([.steps[]? | select(.name == "Preserve CodeQL SARIF evidence" and .conclusion == "success")] | length == 1) + | select( + [.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate") | .conclusion] as $gate + | ($gate | length) == 1 + and ( + ($state == "success" and $gate[0] == "success") + or ($state == "failure" and $gate[0] == "failure") + or ($state == "error" and $gate[0] != "success" and $gate[0] != "failure") + ) + ) + | .run_attempt + ] | if length == 1 then .[0] | tostring else empty end + ')" + [[ "$job_attempt" =~ ^[1-9][0-9]*$ ]] || continue + artifact_name="codeql-dispatch-${LANGUAGE}-${producer_run_id}-${job_attempt}" + if ! artifacts="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/artifacts?name=${artifact_name}&per_page=100" 2>/dev/null)"; then + continue + fi + if printf '%s' "$artifacts" | jq -e --arg name "$artifact_name" ' + [.[]?.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' >/dev/null; then + receipt_evidence="$( + jq -c --argjson run_id "$producer_run_id" --arg state "$state" \ + '. + [{run_id:$run_id,state:$state}] | unique_by([.run_id,.state])' \ + <<<"$receipt_evidence" + )" + fi + done < <(printf '%s' "$statuses" | jq -c \ + --arg ctx "$receipt_context" --arg receipt "$receipt_description" ' + .[][] + | select(.context == $ctx and .description == $receipt) + | select(.state == "success" or .state == "failure" or .state == "error") + | select((.target_url // "") | test("^https://github[.]com/ContextualWisdomLab/[.]github/actions/runs/[1-9][0-9]*$")) + ') + printf '%s\n' "$receipt_evidence" + } + trusted_direct_evidence() { + expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${PR_HEAD_SHA}/${PR_BASE_SHA}/${REQUIRED_RUN_ID}/${PRODUCER_SOURCE_SHA}" + if ! producer_runs="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/workflows/codeql-scan-dispatch.yml/runs?event=repository_dispatch&per_page=100" 2>/dev/null)"; then + return 1 + fi + direct_evidence='[]' + while IFS= read -r producer_run_id; do + [[ "$producer_run_id" =~ ^[1-9][0-9]*$ ]] || continue + producer_run="$(gh api "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}" 2>/dev/null)" || continue + handler_source_sha="$(printf '%s' "$producer_run" | jq -r '.head_sha // empty')" + handler_source_is_compatible "$handler_source_sha" || continue + if ! printf '%s' "$producer_run" | jq -e \ + --argjson run_id "$producer_run_id" --arg title "$expected_title" ' + .id == $run_id + and .event == "repository_dispatch" + and .path == ".github/workflows/codeql-scan-dispatch.yml" + and .head_branch == "main" + and .display_title == $title + and .repository.full_name == "ContextualWisdomLab/.github" + and ((.actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + and ((.triggering_actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + ' >/dev/null; then + continue + fi + producer_jobs="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/jobs?filter=latest&per_page=100" 2>/dev/null)" || continue + direct="$(printf '%s' "$producer_jobs" | jq -c --arg name "CodeQL dispatch scan (${LANGUAGE})" ' + [.[]?.jobs[]? | select(.name == "validate-dispatch" and .status == "completed" and .conclusion == "success")] as $validate + | [.[]?.jobs[]? | select(.name == $name and .status == "completed") + | select([.steps[]? | select(.name == "Preserve CodeQL SARIF evidence" and .conclusion == "success")] | length == 1) + | {attempt:.run_attempt, gate:([.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate") | .conclusion] | if length == 1 then .[0] else "" end)}] as $scan + | if ($validate | length) == 1 and ($scan | length) == 1 + and ($scan[0].attempt | type) == "number" and $scan[0].attempt >= 1 + and ($scan[0].gate == "success" or $scan[0].gate == "failure") + then $scan[0] else empty end + ')" + [ -n "$direct" ] || continue + job_attempt="$(printf '%s' "$direct" | jq -r '.attempt')" + artifact_name="codeql-dispatch-${LANGUAGE}-${producer_run_id}-${job_attempt}" + artifacts="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/artifacts?name=${artifact_name}&per_page=100" 2>/dev/null)" || continue + printf '%s' "$artifacts" | jq -e --arg name "$artifact_name" ' + [.[]?.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' >/dev/null || continue + evidence_state="$(printf '%s' "$direct" | jq -r 'if .gate == "success" then "success" else "failure" end')" + direct_evidence="$( + jq -c --argjson run_id "$producer_run_id" --arg state "$evidence_state" \ + '. + [{run_id:$run_id,state:$state}] | unique_by([.run_id,.state])' \ + <<<"$direct_evidence" + )" + done < <(printf '%s' "$producer_runs" | jq -r --arg title "$expected_title" ' + [.[]?.workflow_runs[]? | select(.display_title == $title) | .id] | unique[] | tostring + ') + printf '%s\n' "$direct_evidence" + } + receipt_evidence="$(trusted_receipt_evidence)" + if ! direct_evidence="$(trusted_direct_evidence)"; then + echo "::error::Unable to enumerate direct CodeQL producer evidence." + exit 1 + fi + verdict_evidence="$( + jq -cn --argjson receipt "$receipt_evidence" --argjson direct "$direct_evidence" \ + '$receipt + $direct | unique_by([.run_id,.state])' + )" + evidence_count="$(printf '%s' "$verdict_evidence" | jq 'length')" + if [ "$evidence_count" -gt 1 ]; then + printf '::error::Ambiguous evidence-complete CodeQL verdict candidates: %s\n' \ + "$(printf '%s' "$verdict_evidence" | jq -c 'sort_by(.run_id, .state)')" >&2 + verdict_state=ambiguous + elif [ "$evidence_count" -eq 1 ]; then + verdict_state="$(printf '%s' "$verdict_evidence" | jq -r '.[0].state')" + else + verdict_state= + fi 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 ;; + ambiguous) + echo "::error::CodeQL shard rejected ambiguous evidence-complete producers for ${LANGUAGE}." + exit 1 + ;; 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 @@ -307,18 +500,16 @@ jobs: 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_BASE_SHA: ${{ needs.detect-languages.outputs.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 }} + PRODUCER_SOURCE_SHA: ${{ github.workflow_sha }} MATRIX: ${{ needs.detect-languages.outputs.matrix }} 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_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." @@ -332,12 +523,26 @@ jobs: 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." + live_base_repository="$(printf '%s' "$live_pr" | jq -r '.base.repo.full_name | select(type == "string")')" + live_base_ref="$(printf '%s' "$live_pr" | jq -r '.base.ref | select(type == "string")')" + live_base_sha="$(printf '%s' "$live_pr" | jq -r '.base.sha | select(type == "string")')" + if [ "$live_base_repository" != "$TARGET_REPOSITORY" ] || + [ -z "$live_base_ref" ] || [ -z "${PR_BASE_REF:-}" ] || + ! [[ "$live_base_sha" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "${PR_BASE_SHA:-}" =~ ^[0-9a-fA-F]{40}$ ]] || + [ "$live_base_ref" != "$PR_BASE_REF" ]; then + echo "::error::CodeQL coordinator rejected malformed live base metadata or a changed base ref." 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." + RERUN_MODE=failed + if [ "${live_base_sha,,}" != "${PR_BASE_SHA,,}" ]; then + echo "::notice::CodeQL live base advanced after the attempt capture; dispatching a whole-attempt refresh." + PR_BASE_SHA="${live_base_sha,,}" + RERUN_MODE=all + fi + if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$PRODUCER_SOURCE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::CodeQL dispatch requires a canonical current run id." exit 1 fi @@ -352,43 +557,247 @@ jobs: gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs" --jq '.jobs[]' | jq -s '{jobs:.}' )" + matrix_job_ids='[]' 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 + job_identity="$(printf '%s' "$jobs_json" | jq -c --arg name "$expected_name" ' + [.jobs[]? | select(.name == $name)] + | if length == 1 then .[0] else empty end ')" + job_id="$(printf '%s' "$job_identity" | jq -r '.id // empty' 2>/dev/null || true)" 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" - )" + matrix_job_ids="$(jq -c --argjson job_id "$job_id" '. + [$job_id]' <<<"$matrix_job_ids")" + if [ "$RERUN_MODE" = "all" ]; then + if [ "$(printf '%s' "$job_identity" | jq -r '.status == "completed" and (.conclusion == "success" or .conclusion == "failure")')" != "true" ]; then + echo "::error::CodeQL whole-attempt refresh requires every matrix job to have a terminal rerunnable conclusion." + exit 1 + fi + required_jobs="$( + jq -c --arg language "$language" --argjson job_id "$job_id" \ + '. + [{language:$language,job_id:$job_id}]' <<<"$required_jobs" + )" + elif [ "$(printf '%s' "$job_identity" | jq -r '.status == "completed" and .conclusion == "failure"')" = "true" ]; then + required_jobs="$( + jq -c --arg language "$language" --argjson job_id "$job_id" \ + '. + [{language:$language,job_id:$job_id}]' <<<"$required_jobs" + )" + fi done < <(printf '%s' "$include_json" | jq -c '.[]') - statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" + unrelated_failed_jobs="$(printf '%s' "$jobs_json" | jq -c --argjson matrix_ids "$matrix_job_ids" ' + [ + .jobs[]? + | select(.status == "completed" and .conclusion == "failure") + | select(.id as $job_id | $matrix_ids | index($job_id) == null) + | .id + ] + ')" + if [ "$(printf '%s' "$unrelated_failed_jobs" | jq 'length')" -ne 0 ]; then + echo "::error::CodeQL coordinator rejected failed jobs outside the exact language map." + exit 1 + fi + + handler_source_is_compatible() { + handler_source_sha="$1" + [[ "$handler_source_sha" =~ ^[0-9a-fA-F]{40}$ ]] || return 1 + protected_branch="$(gh api "repos/ContextualWisdomLab/.github/branches/main" 2>/dev/null)" || return 1 + protected_tip="$(printf '%s' "$protected_branch" | jq -r '.commit.sha // empty')" + if [ "$(printf '%s' "$protected_branch" | jq -r '.protected == true')" != "true" ] || + ! [[ "$protected_tip" =~ ^[0-9a-fA-F]{40}$ ]]; then + return 1 + fi + if [ "${handler_source_sha,,}" = "${protected_tip,,}" ]; then + return 0 + fi + source_compare="$(gh api "repos/ContextualWisdomLab/.github/compare/${handler_source_sha}...${protected_tip}" 2>/dev/null)" || return 1 + printf '%s' "$source_compare" | jq -e \ + --arg source "${handler_source_sha,,}" ' + .status == "ahead" + and .behind_by == 0 + and ((.base_commit.sha // "" | ascii_downcase) == $source) + and ((.merge_base_commit.sha // "" | ascii_downcase) == $source) + ' >/dev/null + } + + statuses="$(gh api --paginate --slurp "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses?per_page=100")" 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 - ')" + LANGUAGE="$language" + trusted_receipt_evidence() { + receipt_context="codeql-dispatch/${LANGUAGE}/${PR_BASE_SHA}" + receipt_description="cwl1;h=${PR_HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${PRODUCER_SOURCE_SHA}" + receipt_evidence='[]' + while IFS= read -r candidate; do + creator="$(printf '%s' "$candidate" | jq -r '.creator.login // "" | ascii_downcase')" + state="$(printf '%s' "$candidate" | jq -r '.state // empty')" + case "$creator" in + opencode-agent|opencode-agent\[bot\]) ;; + github-actions\[bot\]) + [ "${TARGET_REPOSITORY,,}" = "contextualwisdomlab/.github" ] || continue + [ "${GITHUB_REPOSITORY,,}" = "contextualwisdomlab/.github" ] || continue + ;; + *) continue ;; + esac + target_url="$(printf '%s' "$candidate" | jq -r '.target_url // empty')" + producer_run_id="${target_url##*/}" + [[ "$producer_run_id" =~ ^[1-9][0-9]*$ ]] || continue + if ! producer_run="$(gh api "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}" 2>/dev/null)"; then + continue + fi + handler_source_sha="$(printf '%s' "$producer_run" | jq -r '.head_sha // empty')" + handler_source_is_compatible "$handler_source_sha" || continue + expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${PR_HEAD_SHA}/${PR_BASE_SHA}/${REQUIRED_RUN_ID}/${PRODUCER_SOURCE_SHA}" + if ! printf '%s' "$producer_run" | jq -e \ + --argjson run_id "$producer_run_id" --arg title "$expected_title" \ + --arg source "$PRODUCER_SOURCE_SHA" ' + .id == $run_id + and .event == "repository_dispatch" + and .path == ".github/workflows/codeql-scan-dispatch.yml" + and .head_branch == "main" + and .display_title == $title + and .repository.full_name == "ContextualWisdomLab/.github" + and ((.actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + and ((.triggering_actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + ' >/dev/null; then + continue + fi + if ! producer_jobs="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/jobs?filter=latest&per_page=100" 2>/dev/null)"; then + continue + fi + if [ "$(printf '%s' "$producer_jobs" | jq '[.[]?.jobs[]? | select(.name == "validate-dispatch" and .status == "completed" and .conclusion == "success")] | length')" -ne 1 ]; then + continue + fi + expected_job="CodeQL dispatch scan (${LANGUAGE})" + job_attempt="$(printf '%s' "$producer_jobs" | jq -r \ + --arg name "$expected_job" --arg state "$state" ' + [ + .[]?.jobs[]? + | select(.name == $name and .status == "completed") + | select( + ($state == "success" and .conclusion == "success") + or ($state != "success" and .conclusion == "failure") + ) + | select([.steps[]? | select(.name == "Preserve CodeQL SARIF evidence" and .conclusion == "success")] | length == 1) + | select( + [.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate") | .conclusion] as $gate + | ($gate | length) == 1 + and ( + ($state == "success" and $gate[0] == "success") + or ($state == "failure" and $gate[0] == "failure") + or ($state == "error" and $gate[0] != "success" and $gate[0] != "failure") + ) + ) + | .run_attempt + ] | if length == 1 then .[0] | tostring else empty end + ')" + [[ "$job_attempt" =~ ^[1-9][0-9]*$ ]] || continue + artifact_name="codeql-dispatch-${LANGUAGE}-${producer_run_id}-${job_attempt}" + if ! artifacts="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/artifacts?name=${artifact_name}&per_page=100" 2>/dev/null)"; then + continue + fi + if printf '%s' "$artifacts" | jq -e --arg name "$artifact_name" ' + [.[]?.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' >/dev/null; then + receipt_evidence="$( + jq -c --argjson run_id "$producer_run_id" --arg state "$state" \ + '. + [{run_id:$run_id,state:$state}] | unique_by([.run_id,.state])' \ + <<<"$receipt_evidence" + )" + fi + done < <(printf '%s' "$statuses" | jq -c \ + --arg ctx "$receipt_context" --arg receipt "$receipt_description" ' + .[][] + | select(.context == $ctx and .description == $receipt) + | select(.state == "success" or .state == "failure" or .state == "error") + | select((.target_url // "") | test("^https://github[.]com/ContextualWisdomLab/[.]github/actions/runs/[1-9][0-9]*$")) + ') + printf '%s\n' "$receipt_evidence" + } + trusted_direct_evidence() { + expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${PR_HEAD_SHA}/${PR_BASE_SHA}/${REQUIRED_RUN_ID}/${PRODUCER_SOURCE_SHA}" + if ! producer_runs="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/workflows/codeql-scan-dispatch.yml/runs?event=repository_dispatch&per_page=100" 2>/dev/null)"; then + return 1 + fi + direct_evidence='[]' + while IFS= read -r producer_run_id; do + [[ "$producer_run_id" =~ ^[1-9][0-9]*$ ]] || continue + producer_run="$(gh api "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}" 2>/dev/null)" || continue + handler_source_sha="$(printf '%s' "$producer_run" | jq -r '.head_sha // empty')" + handler_source_is_compatible "$handler_source_sha" || continue + if ! printf '%s' "$producer_run" | jq -e \ + --argjson run_id "$producer_run_id" --arg title "$expected_title" ' + .id == $run_id and .event == "repository_dispatch" + and .path == ".github/workflows/codeql-scan-dispatch.yml" + and .head_branch == "main" + and .display_title == $title + and .repository.full_name == "ContextualWisdomLab/.github" + and ((.actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + and ((.triggering_actor.login // "" | ascii_downcase) == "opencode-agent[bot]") + ' >/dev/null; then + continue + fi + producer_jobs="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/jobs?filter=latest&per_page=100" 2>/dev/null)" || continue + direct="$(printf '%s' "$producer_jobs" | jq -c --arg name "CodeQL dispatch scan (${LANGUAGE})" ' + [.[]?.jobs[]? | select(.name == "validate-dispatch" and .status == "completed" and .conclusion == "success")] as $validate + | [.[]?.jobs[]? | select(.name == $name and .status == "completed") + | select([.steps[]? | select(.name == "Preserve CodeQL SARIF evidence" and .conclusion == "success")] | length == 1) + | {attempt:.run_attempt, gate:([.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate") | .conclusion] | if length == 1 then .[0] else "" end)}] as $scan + | if ($validate | length) == 1 and ($scan | length) == 1 + and ($scan[0].attempt | type) == "number" and $scan[0].attempt >= 1 + and ($scan[0].gate == "success" or $scan[0].gate == "failure") + then $scan[0] else empty end + ')" + [ -n "$direct" ] || continue + job_attempt="$(printf '%s' "$direct" | jq -r '.attempt')" + artifact_name="codeql-dispatch-${LANGUAGE}-${producer_run_id}-${job_attempt}" + artifacts="$(gh api --paginate --slurp "repos/ContextualWisdomLab/.github/actions/runs/${producer_run_id}/artifacts?name=${artifact_name}&per_page=100" 2>/dev/null)" || continue + printf '%s' "$artifacts" | jq -e --arg name "$artifact_name" ' + [.[]?.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' >/dev/null || continue + evidence_state="$(printf '%s' "$direct" | jq -r 'if .gate == "success" then "success" else "failure" end')" + direct_evidence="$( + jq -c --argjson run_id "$producer_run_id" --arg state "$evidence_state" \ + '. + [{run_id:$run_id,state:$state}] | unique_by([.run_id,.state])' \ + <<<"$direct_evidence" + )" + done < <(printf '%s' "$producer_runs" | jq -r --arg title "$expected_title" ' + [.[]?.workflow_runs[]? | select(.display_title == $title) | .id] | unique[] | tostring + ') + printf '%s\n' "$direct_evidence" + } + receipt_evidence="$(trusted_receipt_evidence)" + if ! direct_evidence="$(trusted_direct_evidence)"; then + echo "::error::Unable to enumerate direct CodeQL producer evidence." + exit 1 + fi + verdict_evidence="$( + jq -cn --argjson receipt "$receipt_evidence" --argjson direct "$direct_evidence" \ + '$receipt + $direct | unique_by([.run_id,.state])' + )" + evidence_count="$(printf '%s' "$verdict_evidence" | jq 'length')" + if [ "$evidence_count" -gt 1 ]; then + printf '::error::Ambiguous evidence-complete CodeQL verdict candidates: %s\n' \ + "$(printf '%s' "$verdict_evidence" | jq -c 'sort_by(.run_id, .state)')" >&2 + verdict_state=ambiguous + elif [ "$evidence_count" -eq 1 ]; then + verdict_state="$(printf '%s' "$verdict_evidence" | jq -r '.[0].state')" + else + verdict_state= + fi case "$verdict_state" in success|failure|error) echo "Found authenticated current-head CodeQL verdict for ${language}: ${verdict_state}." ;; + ambiguous) + echo "::error::CodeQL coordinator rejected ambiguous evidence-complete receipts for ${language}." + exit 1 + ;; *) pending_matrix="$(jq -c --argjson entry "$entry" '. + [$entry]' <<<"$pending_matrix")" ;; @@ -400,17 +809,26 @@ jobs: exit 0 fi - required_jobs="$( + unmapped_pending_languages="$( jq -nc --argjson pending "$pending_matrix" --argjson jobs "$required_jobs" ' - ($pending | map(.language)) as $langs - | [$jobs[] | select(.language as $l | $langs | index($l) != null)] + ($jobs | map(.language)) as $failed_languages + | [$pending[].language | select(. as $language | $failed_languages | index($language) == 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." + if [ "$(printf '%s' "$unmapped_pending_languages" | jq 'length')" -ne 0 ]; then + echo "::error::CodeQL coordinator could not bind every pending language to an exact failed job." + exit 1 + fi + rerun_matrix="$( + jq -nc --argjson matrix "$include_json" --argjson jobs "$required_jobs" ' + ($jobs | map(.language)) as $failed_languages + | [$matrix[] | select(.language as $language | $failed_languages | index($language) != null)] + ' + )" + if [ "$(printf '%s' "$rerun_matrix" | jq 'length')" -ne "$(printf '%s' "$required_jobs" | jq 'length')" ]; then + echo "::error::CodeQL coordinator could not bind the full rerunnable job set to its language matrix." 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 @@ -431,12 +849,14 @@ jobs: 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 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 producer_source_sha "$PRODUCER_SOURCE_SHA" \ + --arg rerun_mode "$RERUN_MODE" \ + --argjson matrix "$rerun_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}}' | + '{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,producer_source_sha:$producer_source_sha,matrix:$matrix,required_run_id:$required_run_id} + if $rerun_mode == "failed" then {required_jobs:$required_jobs} else {rerun_request:{mode:$rerun_mode,required_jobs:$required_jobs}} end)}' | 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 index c94fdf55c2..918b02597f 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -16,9 +16,10 @@ 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 }} + github.event.client_payload.required_run_id || github.run_id }}/${{ + github.event.client_payload.producer_source_sha || 'missing-source' }} on: repository_dispatch: @@ -52,6 +53,8 @@ jobs: matrix: ${{ steps.validate.outputs.matrix }} required_run_id: ${{ steps.validate.outputs.required_run_id }} required_jobs: ${{ steps.validate.outputs.required_jobs }} + rerun_mode: ${{ steps.validate.outputs.rerun_mode }} + producer_source_sha: ${{ steps.validate.outputs.producer_source_sha }} steps: - name: Exchange OpenCode app token for target repository metadata reads id: metadata_read_app_token @@ -144,11 +147,18 @@ 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_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_LEGACY_HEAD_REF: ${{ github.event.client_payload.pr_head_ref || '' }} + SUPPLIED_LEGACY_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }} + SUPPLIED_PRODUCER_SOURCE_SHA: ${{ github.event.client_payload.producer_source_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) }} + SUPPLIED_RERUN_MODE: ${{ github.event.client_payload.rerun_mode || '' }} + SUPPLIED_RERUN_REQUEST: ${{ toJSON(github.event.client_payload.rerun_request) }} # 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. @@ -177,14 +187,61 @@ 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 + if [ "$(printf '%s' "$SUPPLIED_HEAD_ENVELOPE" | jq -r ' + type == "object" + and ((.ref | type) == "string") + and ((.sha | type) == "string") + ' 2>/dev/null || true)" != "true" ]; then + printf '::error::repository_dispatch supplied invalid pr_head envelope; ref and sha must be strings.\n' + exit 1 + fi + envelope_schema_type="$(printf '%s' "$SUPPLIED_HEAD_ENVELOPE" | jq -r '.schema | type')" + if [ "$envelope_schema_type" = "null" ]; then + printf '::error::repository_dispatch supplied unsupported pr_head schema=.\n' + exit 1 + fi + if [ "$envelope_schema_type" != "string" ]; then + printf '::error::repository_dispatch supplied invalid pr_head envelope; schema must be a string.\n' + exit 1 + fi + envelope_schema="$(printf '%s' "$SUPPLIED_HEAD_ENVELOPE" | jq -r '.schema')" + envelope_ref="$(printf '%s' "$SUPPLIED_HEAD_ENVELOPE" | jq -r '.ref')" + envelope_sha="$(printf '%s' "$SUPPLIED_HEAD_ENVELOPE" | jq -r '.sha')" + if [ "$envelope_schema" != "1" ]; then + printf '::error::repository_dispatch supplied unsupported pr_head schema=%s.\n' "$envelope_schema" + exit 1 + fi + if [ "$SUPPLIED_HEAD_SCHEMA" != "$envelope_schema" ] || + [ "$SUPPLIED_HEAD_REF" != "$envelope_ref" ] || + [ "$SUPPLIED_HEAD_SHA" != "$envelope_sha" ]; then + printf '::error::repository_dispatch pr_head envelope disagrees with extracted workflow inputs.\n' + exit 1 + fi + if { [ -n "$SUPPLIED_LEGACY_HEAD_REF" ] || [ -n "$SUPPLIED_LEGACY_HEAD_SHA" ]; } && + { [ "$SUPPLIED_LEGACY_HEAD_REF" != "$envelope_ref" ] || + [ "$SUPPLIED_LEGACY_HEAD_SHA" != "$envelope_sha" ]; }; then + printf '::error::repository_dispatch rejected conflicting nested and legacy pr_head identity.\n' + exit 1 + fi + elif [ -n "$SUPPLIED_HEAD_SCHEMA" ]; then + printf '::error::repository_dispatch supplied unsupported pr_head schema=%s.\n' "$SUPPLIED_HEAD_SCHEMA" + exit 1 + 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:-}" exit 1 fi + if ! [[ "$SUPPLIED_PRODUCER_SOURCE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::CodeQL producer source is missing or malformed." + 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)" + rerun_request_json="$(printf '%s' "$SUPPLIED_RERUN_REQUEST" | 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" ] || @@ -192,6 +249,30 @@ jobs: 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 + rerun_mode="${SUPPLIED_RERUN_MODE:-failed}" + if [ "$rerun_mode" != "failed" ] && [ "$rerun_mode" != "all" ]; then + printf '::error::CodeQL rerun mode is invalid.\n' + exit 1 + fi + if [ -n "$rerun_request_json" ] && [ "$rerun_request_json" != "null" ]; then + if [ -n "$jobs_json" ] && [ "$(printf '%s' "$jobs_json" | jq '(. != null) and (. != [])')" = "true" ] || + [ -n "$SUPPLIED_RERUN_MODE" ] || [ -n "$SUPPLIED_REQUIRED_JOB_ID" ] || + [ -n "$SUPPLIED_REQUIRED_LANGUAGE" ]; then + printf '::error::CodeQL dispatch rejected conflicting legacy and nested rerun envelopes.\n' + exit 1 + fi + if [ "$(printf '%s' "$rerun_request_json" | jq ' + type == "object" + and ((keys | sort) == ["mode", "required_jobs"]) + and (.mode == "failed" or .mode == "all") + and (.required_jobs | type == "array") + ')" != "true" ]; then + printf '::error::CodeQL rerun mode or required job envelope is invalid.\n' + exit 1 + fi + rerun_mode="$(printf '%s' "$rerun_request_json" | jq -r '.mode')" + jobs_json="$(printf '%s' "$rerun_request_json" | jq -c '.required_jobs')" + 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" ] && @@ -214,6 +295,7 @@ jobs: )) and (($jobs | map(.language) | sort) == ($matrix | map(.language) | sort)) and (($jobs | map(.language) | unique | length) == ($jobs | length)) + and (($jobs | map(.job_id | tostring) | 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 @@ -231,6 +313,7 @@ jobs: 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_merge_commit_sha="$(jq -r '.merge_commit_sha // empty' <<<"$pull_request_json")" live_state="$(jq -r '.state // empty' <<<"$pull_request_json")" if [ "$live_state" != "open" ] || @@ -253,6 +336,24 @@ jobs: 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 + if ! [[ "$live_merge_commit_sha" =~ ^[0-9a-fA-F]{40}$ ]] || + [ "${SUPPLIED_PRODUCER_SOURCE_SHA,,}" != "${live_merge_commit_sha,,}" ]; then + echo "::error::CodeQL producer revision does not match the live pull request merge revision." + exit 1 + fi + producer_commit_json="$(gh api "repos/${TARGET_REPOSITORY}/git/commits/${SUPPLIED_PRODUCER_SOURCE_SHA}")" + if ! printf '%s' "$producer_commit_json" | jq -e \ + --arg source "${SUPPLIED_PRODUCER_SOURCE_SHA,,}" \ + --arg base "${live_base_sha,,}" \ + --arg head "${live_head_sha,,}" ' + ((.sha // "" | ascii_downcase) == $source) + and ((.parents // []) | length == 2) + and ((.parents[0].sha // "" | ascii_downcase) == $base) + and ((.parents[1].sha // "" | ascii_downcase) == $head) + ' >/dev/null; then + echo "::error::CodeQL producer revision is not the exact live base/head merge." + exit 1 + fi { printf 'target_repository=%s\n' "$TARGET_REPOSITORY" @@ -265,6 +366,8 @@ jobs: printf '%s\n' "$matrix_json" echo "EOF" printf 'required_run_id=%s\n' "$SUPPLIED_REQUIRED_RUN_ID" + printf 'rerun_mode=%s\n' "$rerun_mode" + printf 'producer_source_sha=%s\n' "$SUPPLIED_PRODUCER_SOURCE_SHA" echo "required_jobs<>"$GITHUB_OUTPUT" - name: Re-validate live pull request metadata before privileged scan + id: live_metadata 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 }} @@ -428,16 +532,18 @@ jobs: run: python3 "$RUNNER_TEMP/codeql_sarif_gate.py" codeql-results-dispatch - name: Preserve CodeQL SARIF evidence + id: sarif_upload 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 + if-no-files-found: error retention-days: 7 - name: Publish CodeQL dispatch status id: publish_status - if: always() + if: always() && steps.live_metadata.outcome == 'success' env: TARGET_APP_STATUS_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} GITHUB_STATUS_READ_TOKEN: ${{ github.token }} @@ -445,10 +551,18 @@ jobs: OPENCODE_APPROVE_STATUS_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} HEAD_SHA: ${{ needs.validate-dispatch.outputs.head_sha }} + BASE_SHA: ${{ needs.validate-dispatch.outputs.base_sha }} + REQUIRED_RUN_ID: ${{ needs.validate-dispatch.outputs.required_run_id }} + PRODUCER_SOURCE_SHA: ${{ needs.validate-dispatch.outputs.producer_source_sha }} LANGUAGE: ${{ matrix.language }} GATE_OUTCOME: ${{ steps.gate.outcome }} + SARIF_UPLOAD_OUTCOME: ${{ steps.sarif_upload.outcome }} run: | set -euo pipefail + if [ "${SARIF_UPLOAD_OUTCOME:-}" != "success" ]; then + echo "::error::CodeQL SARIF evidence was not preserved; terminal status publication and exact-run settlement are blocked." + exit 1 + fi case "$GATE_OUTCOME" in success) state="success" @@ -463,6 +577,7 @@ jobs: description="CodeQL dispatch scan did not produce a verdict (${GATE_OUTCOME:-unknown})" ;; esac + receipt_description="cwl1;h=${HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${PRODUCER_SOURCE_SHA}" post_status() { token_label="$1" @@ -474,13 +589,34 @@ jobs: 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 context="codeql-dispatch/${LANGUAGE}/${BASE_SHA}" \ + -f description="$receipt_description" \ -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ >"$status_response" 2>"$status_error"; then + actual_creator="$(jq -r '.creator.login // "" | ascii_downcase' "$status_response" 2>/dev/null || true)" + creator_trusted=false + case "$token_label" in + target-app-token|pr-review-merge-token|opencode-approve-token) + case "$actual_creator" in + opencode-agent|opencode-agent\[bot\]) creator_trusted=true ;; + esac + ;; + github-token) + if [ "${TARGET_REPOSITORY,,}" = "contextualwisdomlab/.github" ] && + [ "${GITHUB_REPOSITORY,,}" = "contextualwisdomlab/.github" ] && + [ "$actual_creator" = "github-actions[bot]" ]; then + creator_trusted=true + fi + ;; + esac + if [ "$creator_trusted" = true ]; then + rm -f "$status_response" "$status_error" + echo "Published CodeQL dispatch status to ${TARGET_REPOSITORY}@${HEAD_SHA} using ${token_label}." + return 0 + fi rm -f "$status_response" "$status_error" - echo "Published CodeQL dispatch status to ${TARGET_REPOSITORY}@${HEAD_SHA} using ${token_label}." - return 0 + echo "::notice::CodeQL dispatch status publish using ${token_label} returned unexpected creator=${actual_creator:-missing}; trying the next configured credential." + return 1 fi error_summary="$(head -n 1 "$status_error" | tr -d '\r' || true)" rm -f "$status_response" "$status_error" @@ -506,79 +642,264 @@ jobs: 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." + echo "::notice::Could not publish the CodeQL dispatch status after all configured credentials failed. The exact completed scan and preserved SARIF artifact remain the authenticated fallback evidence." 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 != '' + settle-required-run: + name: settle exact required run + needs: [validate-dispatch, scan] + if: >- + always() + && needs.validate-dispatch.result == 'success' + && needs.scan.result != 'cancelled' + && needs.scan.result != 'skipped' + runs-on: ubuntu-24.04 + timeout-minutes: 8 + permissions: + actions: write + contents: read + id-token: write + steps: + - name: Exchange OpenCode app token for run settlement + 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: Settle exact CodeQL required run env: - GH_TOKEN: ${{ needs.validate-dispatch.outputs.target_repository == github.repository && github.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} + TARGET_APP_WAKE_TOKEN: ${{ steps.target_app_token.outputs.token || '' }} + PR_REVIEW_MERGE_WAKE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || '' }} + OPENCODE_APPROVE_WAKE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN || '' }} + GITHUB_WAKE_TOKEN: ${{ needs.validate-dispatch.outputs.target_repository == github.repository && github.token || '' }} + HANDLER_READ_TOKEN: ${{ github.token }} TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} PR_NUMBER: ${{ needs.validate-dispatch.outputs.pr_number }} + BASE_REF: ${{ needs.validate-dispatch.outputs.base_ref }} + BASE_SHA: ${{ needs.validate-dispatch.outputs.base_sha }} + HEAD_REF: ${{ needs.validate-dispatch.outputs.head_ref }} 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' }} + RERUN_MODE: ${{ needs.validate-dispatch.outputs.rerun_mode }} + PRODUCER_SOURCE_SHA: ${{ needs.validate-dispatch.outputs.producer_source_sha }} run: | set -euo pipefail - if [ -z "${GH_TOKEN:-}" ] || [ "$WAKE_TOKEN_SOURCE" = "unavailable" ]; then - echo "::error::Actions-capable CodeQL wake credential is unavailable." + + run_api() { + token_label="$1" + token="$2" + shift 2 + if [ -z "$token" ]; then + return 1 + fi + if GH_TOKEN="$token" gh api "$@"; then + echo "::notice::CodeQL settlement API used ${token_label}." >&2 + return 0 + fi + echo "::notice::CodeQL settlement API using ${token_label} did not succeed." >&2 + return 1 + } + + github_api() { + run_api "target-app-token" "$TARGET_APP_WAKE_TOKEN" "$@" || + run_api "pr-review-merge-token" "$PR_REVIEW_MERGE_WAKE_TOKEN" "$@" || + run_api "opencode-approve-token" "$OPENCODE_APPROVE_WAKE_TOKEN" "$@" || + run_api "github-token" "$GITHUB_WAKE_TOKEN" "$@" + } + + if ! pull="$(github_api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + echo "::error::CodeQL settlement could not read the current pull request." 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." + if [ "$(printf '%s' "$pull" | jq -r '.state // empty')" != "open" ] || + [ "$(printf '%s' "$pull" | jq -r '.base.repo.full_name // empty')" != "$TARGET_REPOSITORY" ] || + [ "$(printf '%s' "$pull" | jq -r '.base.ref // empty')" != "$BASE_REF" ] || + [ "$(printf '%s' "$pull" | jq -r '.base.sha // empty')" != "$BASE_SHA" ] || + [ "$(printf '%s' "$pull" | jq -r '.head.repo.full_name // empty')" != "$TARGET_REPOSITORY" ] || + [ "$(printf '%s' "$pull" | jq -r '.head.ref // empty')" != "$HEAD_REF" ] || + [ "$(printf '%s' "$pull" | jq -r '.head.sha // empty')" != "$HEAD_SHA" ]; then + echo "::error::CodeQL settlement rejected a closed PR, changed base, or stale head." 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." + if ! required_run="$(github_api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}")"; then + echo "::error::CodeQL settlement could not read the required run." 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" ' + if [ "$(printf '%s' "$required_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." + ')" != "$REQUIRED_RUN_ID" ]; then + echo "::error::CodeQL settlement rejected the required run identity." + exit 1 + fi + + if ! required_job_pages="$(github_api --paginate --slurp "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs?per_page=100")"; then + echo "::error::CodeQL settlement could not read the required jobs." exit 1 fi + required_job_list="$(printf '%s' "$required_job_pages" | jq -c '[.[] | .jobs[]?]')" + while IFS= read -r required_job; do + language="$(printf '%s' "$required_job" | jq -r '.language // empty')" + job_id="$(printf '%s' "$required_job" | jq -r '.job_id // empty')" + expected_name="CodeQL compatibility analysis (${language})" + match_count="$(printf '%s' "$required_job_list" | jq --arg language "$language" --arg name "$expected_name" --arg head "$HEAD_SHA" --argjson run_id "$REQUIRED_RUN_ID" --argjson job_id "$job_id" --arg mode "$RERUN_MODE" ' + [.[] | select( + .id == $job_id + and .run_id == $run_id + and .head_sha == $head + and .name == $name + and .status == "completed" + and ( + ($mode == "failed" and .conclusion == "failure") + or ($mode == "all" and (.conclusion == "success" or .conclusion == "failure")) + ) + )] | length + ')" + if [ "$match_count" -ne 1 ]; then + echo "::error::CodeQL settlement rejected missing or ambiguous exact job identity for ${language}." + exit 1 + fi + done < <(printf '%s' "$REQUIRED_JOBS" | jq -c '.[]') + + required_job_ids="$(printf '%s' "$REQUIRED_JOBS" | jq -c '[.[].job_id]')" + if [ "$RERUN_MODE" = "failed" ] && + [ "$(printf '%s' "$required_job_list" | jq --argjson required_ids "$required_job_ids" ' + [.[] | .id as $id | select(.status == "completed" and .conclusion == "failure" and ($required_ids | index($id) | not))] | length + ')" -ne 0 ]; then + echo "::error::CodeQL settlement rejected unrelated failed jobs outside the exact language map." + exit 1 + fi + + if ! handler_job_pages="$(GH_TOKEN="$HANDLER_READ_TOKEN" gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?per_page=100")" || + ! handler_artifact_pages="$(GH_TOKEN="$HANDLER_READ_TOKEN" gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/artifacts?per_page=100")"; then + echo "::error::CodeQL settlement could not read exact handler evidence." + exit 1 + fi + handler_jobs="$(printf '%s' "$handler_job_pages" | jq -c '[.[] | .jobs[]?]')" + handler_artifacts="$(printf '%s' "$handler_artifact_pages" | jq -c '[.[] | .artifacts[]?]')" + while IFS= read -r required_job; do + language="$(printf '%s' "$required_job" | jq -r '.language')" + expected_job_name="CodeQL dispatch scan (${language})" + expected_artifact_name="codeql-dispatch-${language}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + handler_job_count="$(printf '%s' "$handler_jobs" | jq --arg name "$expected_job_name" --argjson attempt "$GITHUB_RUN_ATTEMPT" ' + [.[] | select( + .name == $name + and .status == "completed" + and (.conclusion == "success" or .conclusion == "failure") + and .run_attempt == $attempt + and ([.steps[]? | select(.name == "Enforce CodeQL Medium+ SARIF gate" and (.conclusion == "success" or .conclusion == "failure"))] | length) == 1 + and ([.steps[]? | select(.name == "Preserve CodeQL SARIF evidence" and .conclusion == "success")] | length) == 1 + )] | length + ')" + handler_artifact_count="$(printf '%s' "$handler_artifacts" | jq --arg name "$expected_artifact_name" ' + [.[] | select(.name == $name and (.expired == false) and (.size_in_bytes > 0))] | length + ')" + if [ "$handler_job_count" -ne 1 ] || [ "$handler_artifact_count" -ne 1 ]; then + echo "::error::CodeQL settlement rejected incomplete handler gate or SARIF evidence for ${language}." + exit 1 + fi + done < <(printf '%s' "$REQUIRED_JOBS" | jq -c '.[]') + + case "$RERUN_MODE" in + failed) rerun_endpoint="rerun-failed-jobs" ;; + all) rerun_endpoint="rerun" ;; + *) + echo "::error::CodeQL settlement rejected an unsupported rerun mode." + exit 1 + ;; + esac + + post_wake() { + token_label="$1" + token="$2" + if [ -z "$token" ]; then + return 1 + fi + if GH_TOKEN="$token" gh api -X POST "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/${rerun_endpoint}" >/dev/null; then + echo "Re-ran exact CodeQL required run ${REQUIRED_RUN_ID} mode=${RERUN_MODE} head=${HEAD_SHA} using ${token_label}." + return 0 + fi + echo "::notice::CodeQL settlement POST using ${token_label} did not succeed." + return 1 + } + + if post_wake "target-app-token" "$TARGET_APP_WAKE_TOKEN" || + post_wake "pr-review-merge-token" "$PR_REVIEW_MERGE_WAKE_TOKEN" || + post_wake "opencode-approve-token" "$OPENCODE_APPROVE_WAKE_TOKEN" || + post_wake "github-token" "$GITHUB_WAKE_TOKEN"; then + exit 0 + 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}." + echo "::error::CodeQL settlement could not enqueue verified run-wide recovery." + exit 1 diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index d32918cf45..33bb0c025c 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -496,6 +496,7 @@ jobs: SCHEDULER_DISPATCH_TOKEN: ${{ github.token }} SCHEDULER_READ_TOKEN: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.target_repository != '' && github.event.client_payload.target_repository != github.repository && (secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token) || github.token }} SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} + SCHEDULER_WORKFLOW_TOKEN: ${{ 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: | diff --git a/.github/workflows/python-security.yml b/.github/workflows/python-security.yml index 8453895027..4ac7f33b48 100644 --- a/.github/workflows/python-security.yml +++ b/.github/workflows/python-security.yml @@ -24,8 +24,8 @@ name: Python Security on: pull_request: + # Scan every PR base ref, including stacked feature branches. types: [opened, synchronize, reopened, ready_for_review, closed] - branches: [main, master, develop] push: branches: [main, master, develop] # Periodic full-repo coverage so non-PR drift is caught (the removed local diff --git a/CHANGELOG.md b/CHANGELOG.md index bf192f6a9e..4932c76845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +### Stale-review cleanup revalidates through the run host credential + +- The destructive-boundary refresh for an active review run now uses the same repository-scoped Actions credential selector as its later cancellation. A denied target-repository read token therefore cannot preserve a stale central `.github` run and suppress current-head dispatch when the central dispatch credential can still authenticate that run. +- The stacked-PR security workflow contract now rejects both `branches` and `branches-ignore` filters, closing the remaining test false-negative that could let a feature-base filter suppress required PR coverage. + +### CodeQL dispatch uses one run-wide settlement owner + +- The producer now keeps `failed`-mode dispatches wire-compatible with the protected pre-cutover handler by sending the complete top-level `required_jobs` map; only the new `all` mode uses `rerun_request:{mode,required_jobs}`. Each payload still has exactly one rerun authority and stays within GitHub's ten-property limit. This repairs handler run `34249932036`, where the protected handler observed `SUPPLIED_REQUIRED_JOBS: null` from #2040's nested-only payload. Refs #2040, #1902. +- Direct-evidence consumers now authenticate a `repository_dispatch` handler source against protected `.github/main`, accepting the exact protected tip or a still-reachable ancestor. They no longer require the target PR's synthetic merge revision to be an ancestor of the handler: GitHub runs those events from different refs and, for product repositories, different histories. Exact target base/head/run/producer provenance remains bound independently in the handler title, payload validation, gate, and SARIF artifact. Refs #2040, #1902. +- A native scan that becomes superseded between initial validation and its privileged scan no longer publishes an `error` status to the unchanged current head: status publication now requires the second live-metadata check and SARIF preservation to succeed, verifies the returned status creator, and emits only `codeql-dispatch//`. The evidence-complete #1902 producer is integrated into the same successor, eliminating the unsafe head-only compatibility context and its circular rollout. Exact evidence: handler run `34235814716`. Refs #2040, #1902. +- Producer provenance is now bound to GitHub's live synthetic pull-request merge revision rather than to an unrelated ancestry relation with the protected handler workflow. The handler requires `producer_source_sha == pull_request.merge_commit_sha`, fetches that immutable commit, and verifies its two ordered parents are the live base and head SHAs. Raw `pr_head` JSON is also type-checked and must agree with independently extracted legacy scalars, so numeric schema coercion and nested-field shadowing fail closed. Refs #2040, #2044, #1902. +- The handler accepts either the legacy top-level rerun fields or #1902's bounded `rerun_request:{mode,required_jobs}` envelope, rejects conflicting or malformed dual authority, and normalizes both to one validated mode/job map. Matrix scans now hold only `actions: read`; after every language has a terminal gate and an exact unexpired SARIF artifact, one non-matrix job revalidates the live PR/base/head and every required job before one run-wide `/rerun-failed-jobs` (`failed`) or `/rerun` (`all`) request. A partial matrix cannot authorize waking an unscanned required language; #1902 must send the complete rerun map as its matrix after this handler lands. This removes the observed race where the first job-level rerun moved the shared workflow and the second received HTTP 403. The sole settlement owner preserves the target App → `PR_REVIEW_MERGE_TOKEN` → `OPENCODE_APPROVE_TOKEN` → same-repository `github.token` fallback chain and fails closed if no request is accepted. Refs #2040, #1902, #1999, #2028, naruon#1592. + ### 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 +81,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 @@ -160,6 +180,26 @@ # Changelog +## Proposed + +- Run Python Security and Agent Review Runtime Quality CI for stacked pull + requests by removing their pull-request base-branch filters. Extend the + permanent stacked-workflow contract so all four owner review workflows + continue covering feature-branch bases. + +- Prove that the scheduler's selected head-mutation credential is present and + distinct from the workflow `github.token`, even when its declared source is + allowlisted. Missing comparison evidence and same-token fallback now fail + closed, and later operator guidance renders from the immutable recorded + decision rather than re-reading mutable environment state. + +- Route scheduler Actions inventory and force-cancellation through the credential + scoped to the repository hosting each run. Central required-workflow runs use + the receiving repository runner token; target runs retain the explicit + cross-repository Actions token. This prevents an exhausted mutation App quota + from blocking current-head review admission while preserving fail-closed + cross-repository authority. + - **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 diff --git a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md index 5a11894767..6851bc903a 100644 --- a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md +++ b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md @@ -99,11 +99,12 @@ codeql-pr.yml (required workflow, runs in target repo context) No codeql-action reference and no repository_dispatch. On attempt one it re-checks the live head, consumes an - authenticated codeql-dispatch/ + authenticated base-bound + 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 + later settles the failed run once. On the woken attempt the shard reads the authenticated current-head status once and reflects it as this job's own exit code. @@ -111,14 +112,15 @@ codeql-pr.yml (required workflow, runs in target repo context) 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 + once with the complete rerun 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. + is required: a run-wide wake re-runs + dependents, and a second dispatch would cancel + the in-flight multi-language handler. A partial + matrix cannot authorize unscanned job ids. .github/workflows/codeql-scan-dispatch.yml (NEW, runs natively in .github, NOT admitted through the ruleset, so codeql-action is unrestricted here) @@ -147,23 +149,26 @@ NOT admitted through the ruleset, so codeql-action is unrestricted here) handler). -- Publish the result as a commit status on the TARGET repository at context - "codeql-dispatch/" using the + "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. + success/failure, a structured description bound + to head/run/producer-source, and target_url + pointing at this .github run's own log. -- 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, + settle-required-run -- After every matrix job is terminal, re-fetch + the open PR and exact failed required workflow + run; require matching repository/base/head, + every distinct run/job/name/conclusion, each + exact gate step and SARIF artifact, and no + unrelated failed job. One actions:write owner + then calls the run-wide rerun endpoint. Missing, stale, closed, or mismatched identity fails - closed and leaves the required job failed. + closed and leaves the required run failed. ``` ### Concurrency identity is per pull request; language independence is the job matrix @@ -177,9 +182,13 @@ still-pending language in a single `codeql-scan` payload (`matrix` plus 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. +matrix. Each scan job publishes `codeql-dispatch/` and preserves its +SARIF evidence. A single non-matrix settlement job runs only after the complete +matrix is terminal, revalidates every required job and language artifact, and +issues one run-wide rerun. A partial matrix is rejected because it cannot prove +an omitted required language without duplicating the producer's receipt trust +logic in the mutation owner. One language's failure cannot cancel or skip a +sibling, and two siblings cannot race mutations on the same required run. #### 2026-09-07 amendment: one dispatch per pull request, adopted for the 60-job ceiling @@ -203,12 +212,135 @@ 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. +superseded. The sibling-cancel failure mode is gone because siblings are jobs +in one run, not runs in one concurrency group. `required_jobs` remains a 1:1 +map of language to distinct canonical job ids. The settlement owner validates +the complete map before one run-wide mutation; a missing, stale, duplicated, +unrelated, or mismatched identity fails closed. The old scalar +`required_job_id`/`required_language` payload remains a bounded compatibility +input for already queued calls only. + +### 2026-09-08 amendment: one attempt-level settlement owner + +Protected handler runs `34220757095` and `34220806323` established two coupled +failures. In the first, the actions shard completed analysis, gate, SARIF, and +status publication and woke the required workflow; the Python shard then +received HTTP 403 because the same workflow was already running. In the +second, #1902's valid ten-property dispatch reached the handler, but the +handler read only legacy top-level `required_jobs` and exposed +`SUPPLIED_REQUIRED_JOBS: null` instead of the nested +`rerun_request.required_jobs`. + +Constraints are: preserve every live repository/PR/base/head/run/job binding; +retain the target-scoped App-token fallback chain; support already queued +legacy payloads without trusting two representations; never let a matrix shard +own Actions mutation; and never rerun unrelated failed work. Alternatives were +rejected as follows: serial job-level reruns retain timing-dependent shared +state; blind cancellation loses valid completed evidence; and copying both +payload representations exceeds or approaches GitHub's ten-property limit and +creates conflicting authority. + +The selected contract accepts exactly one of legacy top-level rerun fields or +`rerun_request:{mode,required_jobs}`, validates `mode` as `failed|all`, requires +unique language and job identities, and normalizes the result. Matrix jobs have +`actions: read`. One `actions: write` settlement job authenticates every +terminal scan and unexpired exact-name SARIF artifact, re-fetches the open PR +and unchanged base/head plus the complete required-run job list, rejects +unrelated failures in `failed` mode, then calls `/rerun-failed-jobs` once or +`/rerun` once. Missing evidence or exhausted credentials terminates without a +mutation. #1902 remains Draft until this handler contract lands normally and +the producer is non-force restacked for exact end-to-end evidence. + +#### 2026-09-09 amendment: stage the wire contract across the protected handler + +**Status: Proposed.** #2040 exact-head required run `34249195529` dispatched +handler run `34249932036` successfully, but the protected pre-cutover handler +read only top-level `required_jobs`. The nested-only producer therefore exposed +`SUPPLIED_REQUIRED_JOBS: null` and failed validation before any scan. + +The selected rollout emits exactly one rerun authority: ordinary `failed` mode +uses the legacy top-level `required_jobs` field that both protected and proposed +handlers validate, while the new whole-attempt `all` mode uses +`rerun_request:{mode,required_jobs}`. Both shapes remain at ten top-level +properties. Sending both was rejected because it would exceed GitHub's limit +and create two authorities; teaching the producer only the new shape before the +default-branch receiver lands was rejected because the repair PR could not +produce its own hosted evidence. After the handler is merged, a follow-up may +retire the legacy shape once no protected or queued consumer requires it. + +#### 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, it requires schema `"1"` and +rejects missing or unknown versions before trusting the tuple. 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. + +#### 2026-09-08 amendment: bind provenance to the live synthetic merge revision + +**Status: Proposed.** A required workflow runs against GitHub's synthetic pull-request +merge commit, while the protected native handler runs from `.github`'s default branch. +Those revisions are from different repositories and histories, so requiring the former +to be an ancestor of the latter is not a valid provenance relation. The selected contract +requires `producer_source_sha` to equal the live pull request's `merge_commit_sha`, fetches +that immutable commit from the target repository, and requires exactly two ordered parents: +the current live base SHA followed by the current live head SHA. A missing, stale, rewritten, +or differently parented merge revision fails before scan or settlement authority is granted. + +The same boundary treats raw JSON as authoritative for type information. `pr_head` must be +an object with string `schema`, `ref`, and `sha`; its values must match the workflow-extracted +scalars, and any independently supplied legacy head fields must be equivalent. This carries +#2044's valid envelope delta into #2040 without duplicating settlement ownership. + +Direct-evidence verification keeps the handler runtime source separate again. A +`repository_dispatch` run executes from central `.github/main`; it does not execute from the +target PR's synthetic merge revision and, for product repositories, cannot share that history. +Consumers therefore require the handler `head_sha` to equal the current protected central-main +tip or be its forward-reachable ancestor. The exact synthetic merge remains authenticated by the +handler against live target base/head parents and remains bound into the title and receipt. This +rejects unprotected, rewritten, sibling, or unrelated handler sources without an impossible +cross-repository ancestry requirement. + +#### 2026-09-08 amendment: stale publication guard and atomic producer integration + +**Status: Proposed.** Handler run `34235814716` passed initial validation, then +correctly rejected both scan shards after the pull request base changed. Its unconditional +publication step nevertheless wrote `error` statuses to the still-current head. The selected +repair gives the second live-metadata validation a stable step identity and permits status +publication only when that step succeeds. A superseded run remains failed evidence but cannot +write a current-head verdict; settlement already requires exact handler gate and SARIF evidence +before any wake mutation. + +The rollout crosses two consumers: the old protected `codeql-pr.yml` reads the head-only +`codeql-dispatch/` context, while #1902 reads the base-bound +`codeql-dispatch//` context. Publishing both was rejected after review: +an old successful head-only status can be reused when the same head is retargeted to a new base or +required run. The selected repair integrates #1902's evidence-complete producer as a second parent +of the same successor and publishes only the base-bound context. The producer and handler therefore +advance atomically, without either an unsafe compatibility receipt or a circular deployment order. +Publication additionally requires a preserved SARIF artifact and verifies that the status response +was created by the credential identity permitted for that target repository. ## Scope decision: `analyze-merge` is dropped, not migrated diff --git a/docs/doctoring/codeql-live-base-terminal-boundary.md b/docs/doctoring/codeql-live-base-terminal-boundary.md new file mode 100644 index 0000000000..9645bda6cb --- /dev/null +++ b/docs/doctoring/codeql-live-base-terminal-boundary.md @@ -0,0 +1,154 @@ +# CodeQL terminal 소비 전 live base 검증 + +기준 `b966f826085f8beabf4884e56ebca1d19b6c74e2`에서는 이미 조회한 PR의 +state/head만 확인하고 terminal status를 소비했다. 이벤트 이후 base가 +바뀌거나 base 정보가 없어도 trusted publisher의 같은-head 성공을 받아들였다. + +기존 handler와 같은 base repository/ref 계약을 소비 직전에 적용한다. 다만 queued +job이 runner를 얻기 전에 protected base tip이 전진할 수 있으므로 event SHA와 live +SHA의 일치를 요구하지 않는다. 이미 받은 live PR 응답의 유효한 SHA를 새 `A`로 삼아 +status context, dispatch payload, handler title과 receipt를 모두 다시 결속한다. base +repository/ref 누락·retarget 또는 잘못된 live SHA에서는 status 조회 전에 실패한다. + +기존 실제 shell/fake-gh 테스트의 fixture를 production `PR_BASE_REF`, +`PR_BASE_SHA`, `PR_HEAD_REF` 이름으로 교정했다. live base 음성은 거부 경로가 +PR GET 한 번만 허용해 status 조회 및 모든 POST가 없음을 확인한다. 별도 RED는 +stale event SHA가 live SHA로 재결속되지 않아 영구 RED가 되는 경로를 재현한다. +정상 publisher·실패 verdict·두 번째 페이지 status 회귀는 유지한다. + +후속 exact-head 보안 검토에서 같은 head가 다른 base로 retarget된 뒤 이전 +trusted status를 재사용할 수 있음이 확인됐다. Producer는 이제 exact head에 +`codeql-dispatch//` context와 +`cwl1;h=;w=codeql-scan-dispatch;r=` receipt를 게시하고, target URL을 +`ContextualWisdomLab/.github`의 숫자 Actions run ID로 제한한다. Consumer는 +publisher identity와 이 필드를 모두 확인한다. Handler run title도 +target repository/PR/head/base/required run에 결속한다. 이전 generic context나 다른 +base/head/workflow/target의 status는 terminal evidence가 아니며 bounded redispatch로 +수렴한다. 실제 이전-base trusted success와 current-base trusted failure를 함께 둔 +RED fixture가 이전 성공을 무시하고 현재 실패를 소비하는지 검증한다. + +## Self-repository publisher identity amendment — 2026-09-08 + +`.github` PR #1962의 required run `34083528482`에서 child handler run +`34098416167`은 target-App status POST의 HTTP 403 뒤 repository +`GITHUB_TOKEN`으로 성공 receipt를 게시했다. 실제 creator는 +`github-actions[bot]`이었고 exact job `101640519643`은 wake됐지만, consumer는 +OpenCode App creator만 허용해 attempt-2 job `101722211580`을 terminal verdict +없는 rerun으로 거부했다. 게시 성공과 소비 가능한 identity가 분리된 것이 원인이다. + +수리는 self repository에만 bounded fallback을 둔다. Consumer는 receipt의 숫자 +run URL을 다시 조회하고 `repository_dispatch`, canonical workflow path, exact +repository/PR/head/base/required run이 포함된 rendered title, OpenCode App actor와 +triggering actor, `validate-dispatch`, 해당 language의 terminal gate, SARIF 보존 +step 성공과 exact run/attempt의 만료되지 않은 artifact를 모두 확인한다. Producer는 +POST response의 creator를 확인한 뒤에만 receipt publication을 성공으로 인정한다. +현재 handler 내부 settlement는 같은 self repo의 `github-actions[bot]` receipt를 +현재 `GITHUB_RUN_ID` URL과 일치할 때만 받는다. + +Status POST가 모두 HTTP 403이면 receipt 자체는 만들 수 없다. 이 경우에도 동일한 +현재 central run identity, successful validation, language gate, SARIF upload 및 +exact unexpired artifact를 직접 재검증하면 terminal evidence로 인정한다. Scan +matrix는 `actions: read`만 가지며, 모든 language가 끝난 뒤 실행되는 단일 non-matrix +settlement job만 `actions: write`를 가진다. 이 경로는 bare 403, run URL 형태 또는 +artifact 이름만으로는 열리지 않는다. Run, job, artifact 조회는 모두 native +pagination의 전체 page를 펼쳐 unique identity를 확인하며 첫 `per_page=100` 응답을 +완전한 증거로 간주하지 않는다. + +Coordinator의 scan matrix와 run-wide settlement map은 서로 다른 집합이다. Trusted +terminal receipt가 있는 language는 중복 scan에서 제외하지만, 그 language의 원래 +compatibility job이 exact required run에서 실패했다면 `required_jobs`에는 유지한다. +반대로 성공 job과 language map 밖의 실패 job은 settlement 권한에 포함하지 않으며, +모든 pending language가 exact failed job에 매핑되지 않으면 dispatch 전에 실패한다. +이 구분이 없으면 Python receipt와 Actions pending이 섞인 경우 Actions만 재스캔한 뒤 +불완전한 job map으로 run-wide settlement가 거부된다. + +Target PR base SHA `A`, synthetic merge source SHA `S`, 중앙 handler runtime source +`T`, 현재 protected `.github/main` tip `P`를 분리한다. `A`와 PR head는 target review +대상을 정하고, required workflow의 immutable `github.workflow_sha`인 `S`는 handler가 +live `merge_commit_sha` 및 ordered base/head parents와 대조한다. Producer는 `S`를 +payload, handler title, terminal receipt에 함께 결속한다. + +`repository_dispatch` receiver는 중앙 repository의 default branch에서 실행되므로 +`T`는 target repository의 `A`나 synthetic merge `S`와 같은 history일 필요가 없다. +Direct-evidence consumer는 중앙 `main` branch가 protected임을 조회하고, `T == P`이거나 +GitHub compare가 `T`를 `P`의 exact merge base로 확인하며 `P`가 ahead이고 +`behind_by == 0`임을 +증명할 때만 handler source를 수용한다. Missing/unprotected/diverged/reversed/malformed +관계는 fail closed한다. 실제 target run `34225089444`는 `S=55a59cf5…`가 PR synthetic +merge임을 보였으므로 `S...T` ancestry를 요구하면 정상 handler evidence도 영구 +거부한다. `referenced_workflows=[]` 같은 optional field도 source authority로 사용하지 +않는다. + +같은 required run을 recovery하면 incomplete predecessor와 successor handler가 동일한 +bound title을 가질 수 있다. Consumer는 title 개수를 먼저 제한하지 않고 각 candidate의 +run metadata, source ancestry, exact language gate, SARIF preservation, unexpired artifact를 +검증한 뒤 evidence-complete candidate가 정확히 하나일 때만 verdict를 수용한다. 따라서 +incomplete predecessor는 successor를 가리지 않으며 complete candidate가 0개 또는 2개 +이상이면 계속 fail closed한다. + +Protected handler 전환도 동일한 exact-evidence 경계를 따른다. #2040 run +`34249195529`가 만든 handler run `34249932036`은 nested-only +`rerun_request`를 protected 구버전 handler에 전달해 `SUPPLIED_REQUIRED_JOBS: null`로 +종료됐다. 전환 중 `failed` mode는 양쪽 handler가 해석하는 top-level +`required_jobs` 하나만 보내고, 새 의미인 whole-attempt `all` mode만 nested envelope를 +사용한다. 두 표현을 함께 보내거나 predecessor 성공을 승계하지 않는다. + +RED는 provenance가 완전한 self fallback 거부, 위조 workflow/title/actor 거부, +required-run 결속 누락, unrelated creator를 반환한 성공 POST의 오승인과 status +write 실패 뒤 직접 evidence 미검증을 각각 재현했다. 다른 repository, 다른 run +URL, 누락된 gate/SARIF/artifact는 계속 fail closed한다. Bot creator를 전역 +allowlist에 넣는 대안은 target workflow가 가진 `statuses:write`만으로 terminal +evidence를 만들 수 있어 채택하지 않았다. + +OpenCode App creator도 그 자체로 terminal evidence가 아니다. Shard와 coordinator는 +App receipt에도 동일한 exact handler run, source ancestry, bound title, completed +successful `validate-dispatch` job 하나, language job, SARIF artifact 계약을 적용한다. +실제 RED는 올바른 App creator가 게시했어도 validation job이 누락·실패·중복되거나, +workflow가 다르거나, language job이 진행 중이거나, artifact가 누락된 receipt가 이전에는 +즉시 success로 수렴함을 재현했고, GREEN에서는 모두 fail closed한다. + +Receipt API에는 같은 context/description을 가진 여러 producer URL이 남을 수 있다. +Shard와 coordinator는 첫 complete receipt에서 반환하지 않고 모든 candidate를 끝까지 +검증한다. 같은 run/state의 반복 기록은 하나로 정규화하지만 서로 다른 complete run이나 +상태가 둘 이상이면 순서로 승자를 고르지 않고 fail closed한다. Coordinator는 이 경우 +exact candidate run ID/state만 기록하고 credential을 요청하거나 새 producer를 dispatch하지 +않는다. 이미 모호한 집합에 세 번째 candidate를 추가하는 행위는 복구가 아니라 unbounded +churn이므로 current source 또는 운영 증거를 수리해야 한다. + +## Attempt-wide base and predecessor settlement amendment — 2026-09-08 + +Matrix shard가 runner를 얻을 때마다 live base를 독립적으로 채택하면 같은 required run의 +앞선 shard는 base `A`, 뒤의 shard와 coordinator는 base `B`를 사용할 수 있다. 특히 +앞선 shard가 성공한 뒤 base가 전진하면 `rerun-failed-jobs`가 그 성공 sibling을 다시 +실행하지 않아 run이 수렴하지 않는다. 이제 `detect-languages`가 matrix 확장 전에 live +PR/head/base를 한 번 검증해 attempt base SHA를 output으로 고정한다. 모든 shard와 +coordinator는 그 값을 사용한다. 이후 live base가 달라지면 shard는 mixed-base evidence를 +거부하고, `always()` coordinator는 새 live base에 결속된 `rerun_mode=all` dispatch를 +만든다. Trusted handler의 단일 `actions: write` settlement가 exact required run의 +whole-run rerun endpoint를 호출하므로 성공했던 `detect-languages`와 모든 matrix shard가 +같은 새 attempt에서 다시 실행된다. Base가 그대로면 기존 `rerun_mode=failed`와 +failed-job-only endpoint를 유지한다. 두 mode 외 payload, terminal이 아닌 matrix job, +language map 밖 실패 job, stale live head/base는 모두 POST 전에 거부한다. + +Dispatch validation 뒤 최대 30분의 handler scan 동안 base가 다시 전진하는 두 번째 +TOCTOU window도 동일 owner가 처리한다. Wake는 open state, exact head, base ref를 다시 +확인하고 old SHA가 new SHA의 merge-base ancestor임을 compare evidence로 증명한 뒤 +old-base receipt를 읽지 않고 exact run의 mode를 `all`로 승격한다. 따라서 종료된 +coordinator나 새 pull-request event에 의존하지 않고 전체 attempt가 새 base를 capture한다. +Retarget, rewrite/divergence, stale head, malformed compare는 계속 fail closed하며, 동시 wake의 +HTTP 403은 기존 exact newer-attempt 증거가 있을 때만 성공으로 수렴한다. + +Mixed terminal/pending matrix에서는 이미 terminal인 language의 receipt가 predecessor +handler run을 가리킬 수 있다. Current handler는 pending language만 scan하므로 모든 +receipt를 current run URL로 제한하면 run-wide settlement가 영구 대기한다. Settlement는 +같은 exact repository/PR/head/base/required-run/source title에 결속된 predecessor run을 +다시 조회하고, OpenCode App actor, immutable source ancestry, terminal language job, +SARIF preservation, exact run-attempt artifact를 전부 검증한다. 유일한 evidence-complete +receipt만 current direct evidence와 결합하며, incomplete/ambiguous/malformed candidate는 +계속 거부한다. + +Receipt의 terminal job conclusion과 SARIF artifact만으로 published state를 추론하지 +않는다. 각 receipt consumer는 `Enforce CodeQL Medium+ SARIF gate` step이 정확히 하나인지 +검사하고 `success→success`, `failure→failure`, `error→그 밖의 conclusion`을 요구한다. +Gate 누락·중복·상태 불일치 fixture는 predecessor receipt를 거부하며, current direct +evidence가 있는 다른 language만으로 required run을 깨우지 못한다. diff --git a/docs/doctoring/codeql-pr-required-workflow-always-fails.md b/docs/doctoring/codeql-pr-required-workflow-always-fails.md index de994b53b0..46a11f0301 100644 --- a/docs/doctoring/codeql-pr-required-workflow-always-fails.md +++ b/docs/doctoring/codeql-pr-required-workflow-always-fails.md @@ -96,3 +96,47 @@ 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. + +## Run-wide settlement credential chain (2026-09-08) + +The native handler's settlement owner must try the same credential order as +Publish CodeQL dispatch status. naruon#1592 run 34185353127 published after +#2028's loop, then selected a nonempty target App token that could not mutate +Actions. Later handler run 34220757095 proved that per-language job reruns also +race: the first accepted request starts the shared workflow and the second is +rejected with HTTP 403. The matrix now holds `actions: read`; one non-matrix +owner authenticates every language's terminal gate and SARIF artifact, then +POSTs one run-wide rerun with each nonempty credential in publish order until +one is accepted. If none is accepted, or any live PR/base/head/run/job evidence +changed, the handler fails closed. See #2040 and #1902. + +The handler also rejects a partial matrix paired with a larger job map. The +producer must rescan the complete rerun map; otherwise an omitted language +could be mutated without current handler evidence. + +## Producer provenance is a target-PR merge binding (2026-09-08) + +The required workflow's `github.workflow_sha` is GitHub's synthetic pull-request merge +revision; the handler's `github.workflow_sha` is a protected `.github` revision. Comparing +ancestry between them is categorically wrong because they belong to different histories. +The handler instead binds the supplied producer revision to the live PR +`merge_commit_sha`, fetches that target-repository commit, and verifies its ordered parents +are the live base and head SHAs. This preserves exact-source evidence without coupling the +producer to a temporary handler branch. Raw nested head JSON is type-checked and must agree +with separately extracted legacy fields before the live PR check. + +## Superseded scan publication and atomic producer integration (2026-09-08) + +Run `34235814716` authenticated the then-live #2040 base/head, but #2040 was retargeted before +its two scan jobs received runners. Both jobs correctly failed the second live-metadata check; +the unconditional publication step then converted the missing gate outcome into `error` and +posted it to the unchanged current head. The handler now publishes only after that second check +succeeds, so stale handler evidence cannot poison a current revision or trigger settlement. + +The first repair proposed publishing the same receipt under both the base-bound +`codeql-dispatch//` context and the protected producer's legacy +`codeql-dispatch/` context. Review rejected that bridge because an old head-only success +can be reused after a same-head base or required-run change. #2040 instead integrates #1902's +evidence-complete producer in the same non-force successor and publishes only the base-bound +context. Status publication also requires preserved SARIF evidence and verifies the creator returned +by the status API before treating a credential attempt as successful. diff --git a/docs/doctoring/codeql-rerun-pre-runner-cancellation-recovery.md b/docs/doctoring/codeql-rerun-pre-runner-cancellation-recovery.md new file mode 100644 index 0000000000..f684d3bc1b --- /dev/null +++ b/docs/doctoring/codeql-rerun-pre-runner-cancellation-recovery.md @@ -0,0 +1,73 @@ +# CodeQL rerun recovery after pre-runner cancellation + +## Problem and exact evidence + +The required `CodeQL PR` workflow used `github.run_attempt != 1` as if it proved that an earlier attempt had successfully dispatched the native CodeQL scan. That inference is false when an earlier attempt is cancelled before runner assignment. + +`ContextualWisdomLab/accounting-information-platform` PR #49 provides the concrete reproduction on exact head `065f9ab7038bf35db4ef129827de6ab8ee6a1038`, workflow run `33890965185`. + +- Attempt 1 `Detect CodeQL languages` job `101082241642` ended `cancelled` with `runner_id=0` and `steps=[]`; its downstream compatibility job was also cancelled without execution. +- Attempt 2 `Detect CodeQL languages` job `101128192785` ended the same way: `cancelled`, `runner_id=0`, `steps=[]`; the downstream compatibility job again never executed. +- Attempt 3 finally obtained runners. The `actions` shard job `101220582725` and `python` shard job `101220582747` reached `Request current-head CodeQL scan dispatch`, found no authenticated `codeql-dispatch/` terminal status, then failed solely because `RUN_ATTEMPT=3`. +- The target exact head had no `codeql-dispatch/actions` or `codeql-dispatch/python` commit status. Thus the attempt number did not identify a prior dispatch receipt or a terminal scan verdict. + +This leaves an unchanged PR head permanently unable to obtain the required CodeQL result even after runner capacity recovers. + +## Chosen repair + +Keep the existing trust sequence: + +1. re-read the live pull request and reject closed or moved heads; +2. read only base-bound `codeql-dispatch//` receipts created by the expected `opencode-agent` identity; for the `.github` self-repository token fallback, require the exact protected dispatcher run, language job, conclusion, and preserved SARIF artifact instead of trusting `github-actions[bot]` or its URL alone; +3. if an authenticated terminal status exists, reflect it without dispatching; +4. otherwise collect the exact failed language-job map, obtain the OIDC-bound app token, and dispatch the pending matrix once for the exact repository/PR/head/base/run. + +Remove the coordinator's `github.run_attempt == 1` veto. A rerun attempt number is execution metadata, not evidence that the coordinator dispatched. Every attempt first checks the complete authenticated receipt history; one with terminal receipts emits no dispatch, while an attempt whose predecessor never ran can recover. + +This does not convert a missing CodeQL verdict to success. Required shards still fail with `verdict=pending`; the handler waits for all trusted terminal receipts, validates the exact run and failed-job map, and settles that run. A forged status, stale head/base, failed/error verdict, unavailable OIDC/app token, malformed run/job identity, extra failed job, or absent receipt remains fail closed. + +## Follow-up review: complete status-history authority + +Current-head review on `e72ae30e3e989396b8cfdd1d850f7db1f45c6a7e` found a second defect in the same evidence boundary. `GET /commits/{sha}/statuses` was read without pagination. Treating an empty default response page as proof that no authenticated terminal `codeql-dispatch/` verdict exists is unsafe on a commit with enough status history to push an older trusted verdict to a later page. The recovery path could then redispatch even though terminal authority already existed. + +The rejected alternatives are increasing an assumed first-page size without pagination, trusting the combined commit-status summary, or restoring `RUN_ATTEMPT` inference. None proves absence of the exact creator-bound language status across the complete history. + +RED `acfa17e84f1ef6a0da5b93c642fcdf0d67d1d814` extends the focused contract to require a paginated, slurped status lookup and page-flattening before absence can authorize redispatch. Minimal repair `7628274f3e146e32fba124fe3e21e1fef8b107b3` changes only that read boundary: `gh api --paginate --slurp .../statuses?per_page=100` collects every page, and the existing trusted-context/creator filter runs across `.[][]`. Live PR/head validation, OIDC/app-token exchange, exact run/job/language binding, pending fail-closed behavior, handler validation and concurrency are unchanged. + +The security effect is narrower than “more reliable pagination”: **absence is now established over the complete status population before dispatch authority is exercised**. An authenticated terminal status on any page therefore prevents a redundant redispatch. If GitHub changes the status API representation, the focused regression must fail rather than silently fall back to first-page semantics. + +## Executable regression + +`tests/test_codeql_pr_rerun_recovery_contract.py` executes the production `Dispatch current-head CodeQL scan` coordinator with: + +- the same live target head; +- only an old-base authenticated CodeQL status; +- mocked OIDC and app-token exchange boundaries; and +- an exact current-run language/job map. + +The test requires later attempts to remain admitted and emit one `codeql-scan` payload for pending languages. The companion status-history contract requires `--paginate --slurp`, an explicit `per_page=100`, and page flattening before the trusted verdict filter. + +Before the production change, the original regression exits at the attempt-number guard before OIDC or dispatch. Before the pagination repair, the status-history contract fails because the production read asks only for the default first page. After both repairs, the same shell block reaches the bounded dispatch path only when the complete authenticated status history contains no terminal verdict. + +## Risks, rollback, and acceptance + +A later required-run attempt while a prior native dispatch is still queued and has no terminal receipt may replace work in the existing central target/PR concurrency lane. This is bounded to the same exact pull request and current head/base. If live evidence shows harmful restart churn, the successor should add an authenticated pending receipt rather than restoring attempt-number inference. + +Pagination adds API reads proportional to commit-status history, bounded at 100 statuses per page. That cost is accepted because a false “verdict absent” decision authorizes external dispatch; status absence therefore requires complete evidence rather than a first-page heuristic. + +Rollback is not `RUN_ATTEMPT != 1` and not a non-paginated status read; either recreates a proven dead end or an incomplete-authority check. A valid replacement must distinguish “prior dispatch accepted” from “prior attempt never executed” using authenticated complete-history evidence and retain exact-head fail-closed semantics. + +GREEN requires all of the following on one unchanged successor head: + +- the focused rerun-recovery and complete-status-history regressions pass; +- the existing `test_codeql_pr_workflow_contract.py` suite remains green; +- the complete central test, 100% coverage, docstring, workflow syntax, security and review gates pass; +- after protected integration, the unchanged accounting-platform PR #49 head is rerun and obtains a real authenticated terminal CodeQL verdict without provider/model or leaf-repository workaround. + +## References + +GitHub. (2026). *Re-running workflows and jobs*. GitHub Docs. https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs + +GitHub. (2026). *REST API endpoints for workflow runs*. GitHub Docs. https://docs.github.com/en/rest/actions/workflow-runs + +GitHub. (2026). *REST API endpoints for commit statuses*. GitHub Docs. https://docs.github.com/en/rest/commits/statuses diff --git a/docs/doctoring/codeql-sarif-publication-boundary.md b/docs/doctoring/codeql-sarif-publication-boundary.md new file mode 100644 index 0000000000..de18b4458e --- /dev/null +++ b/docs/doctoring/codeql-sarif-publication-boundary.md @@ -0,0 +1,25 @@ +# CodeQL SARIF publication boundary + +The central CodeQL dispatch handler publishes a terminal commit status only after the same matrix shard has successfully preserved its SARIF artifact. A successful finding gate without durable evidence is not a successful scan contract: upload failure, a skipped upload, cancellation, or a missing outcome fails closed before any status credential is used and therefore before exact-run settlement can begin. + +`actions/upload-artifact` owns the evidence boundary. The upload step has a stable step identifier and rejects an empty artifact input. The status-publication step consumes that step's outcome and accepts only `success`; it does not infer preservation from a generated local file or from the SARIF gate result. The gate result continues to determine whether preserved evidence represents a passing or failing security verdict. + +Executable regression coverage runs the real publication shell against a fixture-backed GitHub API. The success control permits one exact-head/base status post. Upload outcomes `failure`, `skipped`, `cancelled`, and empty each exit before a post, preventing a false terminal success and downstream exact-run settlement. + +This source repair does not change repository-dispatch actor authorization or cross-repository credential authority. Those remain separate configuration and GitHub App permission boundaries tracked in ContextualWisdomLab/.github issue #1929. + +## 로컬 회귀와 남은 경계 + +기준 `fe64f24931ec91b8578edb5b5eadf219074a52a7`의 실제 게시 shell은 +upload failure/skipped/빈 값/cancelled에서 success POST와 mock wake가 +발생해 RED였다. 통합 테스트는 이 네 조건과 정상 success, finding failure, +gate skipped의 error를 한 테이블로 검증하며 실제 게시 state와 mock settlement를 +함께 확인한다. 외부 API나 실제 scan을 실행한 증거는 아니다. + +이는 전체 receipt 또는 dedupe 수리가 아니다. 기대 trusted workflow SHA의 +독립적인 출처와 cross-repository artifact 읽기 권한은 여전히 후속 gate다. +기존 terminal status를 publisher·head·language만으로 재사용하여 다른 +base/workflow의 성공을 승계할 수 있는 소비자 취약점도 이 업로드 수리로 해결되지 않는다. +동일 입력 증명이나 admission 원자성을 단독으로 보장하지 않는다. 자동 settlement의 +exact-run/job/receipt 경계는 ADR-0025의 2026-09-08 amendment에 기록한다. +별도 live base 검증의 범위는 [소비 경계](codeql-live-base-terminal-boundary.md)에 기록한다. diff --git a/docs/doctoring/codeql-wake-credential-fallback-boundary.md b/docs/doctoring/codeql-wake-credential-fallback-boundary.md new file mode 100644 index 0000000000..af5629921d --- /dev/null +++ b/docs/doctoring/codeql-wake-credential-fallback-boundary.md @@ -0,0 +1,44 @@ +# CodeQL wake credential fallback boundary + +## Symptom + +The trusted handler could finish exact PR, head, base, run, job, receipt, gate, +SARIF, and handler-source validation but still fail to wake the required run. +The wake job selected the first nonempty credential in the workflow expression; +if that credential returned HTTP 403 for the target repository, a later valid +credential was never attempted. + +## Root cause + +Credential presence was treated as evidence of repository-scoped Actions +authority. That assumption is false for central workflows serving multiple +repositories. It also made the fallback decision before the only operation +that can establish whether the credential is admitted. + +## Reproduction and repair evidence + +- Owner: `ContextualWisdomLab/.github` PR #1902. +- Successor delta source: PR #2040, retained in the canonical run-wide + settlement rather than copying its earlier per-matrix wake structure. +- RED: commit `be8702379171e7aa2f53d887326c524c20ee26a6` records two POST attempts only after the primary is + made to return HTTP 403; the predecessor emitted one failed POST. +- GREEN: commit `376230157ea9303267defe28bf519d69c5875ae2` tries the bounded credential chain and succeeds on + the second credential against the identical exact-run endpoint. +- Contract evidence: the focused fallback fixture and all 63 dispatch workflow + contracts pass locally. Hosted exact-head evidence is still required. + +## Invariants and failure scenes + +The wake remains owned by one non-matrix settlement job. Every credential is +subject to the same exact endpoint and the same revalidated PR, head, base, +workflow path, run, job map, receipt, SARIF, and producer provenance. If all +eligible credentials are absent or denied, the handler fails closed. A bare +HTTP 403 never counts as a concurrent wake; only exact newer attempts for every +required language can prove that race. The scan job's repository-scoped App +token remains local to that matrix job and is not serialized or transferred. + +For an operator, the actionable distinction is now explicit: a denied primary +credential advances to the next bounded credential, while total exhaustion +leaves the required Check red with no broadened authority. For a reviewer, the +fixture proves both POSTs target the same run and mode, so fallback cannot be +used to rerun a different workflow or commit. diff --git a/docs/doctoring/host-scoped-actions-inventory-credentials.md b/docs/doctoring/host-scoped-actions-inventory-credentials.md new file mode 100644 index 0000000000..d897f2b91a --- /dev/null +++ b/docs/doctoring/host-scoped-actions-inventory-credentials.md @@ -0,0 +1,58 @@ +# Host-scoped Actions inventory credentials + +Decision date: **2026-09-07** + +## Problem + +The central scheduler reads and cancels workflow runs in two authority domains. +Runs hosted by `ContextualWisdomLab/.github` are visible to the receiving +workflow's runner token. Runs hosted by a target repository require the explicit +cross-repository Actions credential. Sending both through the mutation App +couples current-head admission to that installation's independent rate-limit +bucket and reproduces the queue blocker recorded in +[ContextualWisdomLab/.github#1231](https://github.com/ContextualWisdomLab/.github/pull/1231). + +## Decision + +Select the credential from the repository that hosts the run. Repository +identity is compared case-insensitively. Central inventory and cancellation use +the configured dispatch/runner token; all target repositories continue through +the explicit Actions token. Missing credentials continue to fail at the GitHub +API boundary—there is no paid, anonymous, or mutable-head fallback. + +The same selection applies to the destructive-boundary active-run refresh, not +only the eventual cancellation request. The target PR/head refresh remains on +the target repository's read credential, while the run refresh and cancellation +share the credential selected from `run_repo`. This prevents a denied general +read token from preserving a proven-stale central run that the central +dispatch/runner token can still authenticate and cancel. + +## Failure scenes + +- If the mutation App quota is exhausted, central current-head discovery still + uses the runner token and can release stale central runs. +- If a target repository is queried, the scheduler never substitutes the + central runner token, whose scope is insufficient. +- If repository casing differs, the same central repository is not + misclassified as a target. +- If the general read token cannot inspect a central Actions run, host-scoped + revalidation still determines whether the run is active before any + cancellation; malformed, completed, or unreadable results remain preserved. + +## Evidence and follow-up + +The permanent regression first appears at RED commit +`8cc62ce8837e456dfac4f592bcbd0786a77e4b81`. The implementation must receive +fresh exact-head GitHub Checks before the PR can leave Proposed status. PR +#2040 adds a production-shaped denial fixture for the later-discovered refresh +seam: before the repair, `_fresh_active_run_for_cancellation` calls the general +read boundary and fails; afterward it calls the host-scoped Actions selector +with the exact run repository and path. + +## References + +GitHub. (2026). *REST API endpoints for workflow runs*. +https://docs.github.com/en/rest/actions/workflow-runs + +GitHub. (2026). *Automatic token authentication*. +https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication diff --git a/docs/doctoring/stacked-python-runtime-review-coverage.md b/docs/doctoring/stacked-python-runtime-review-coverage.md new file mode 100644 index 0000000000..3e32484549 --- /dev/null +++ b/docs/doctoring/stacked-python-runtime-review-coverage.md @@ -0,0 +1,36 @@ +# Stacked Python and runtime review coverage + +Decision date: **2026-09-07** + +## Incident + +A pull request targeting the feature branch for `ContextualWisdomLab/.github#2002` +created Security Scan, SAST Semgrep, and CodeQL PR runs, but no Python Security +or Agent Review Runtime Quality CI run. Both missing workflows restricted the +`pull_request` base branch, while the existing stacked-PR regression covered +only Security Scan and SAST Semgrep. + +## Decision + +All four owner review workflows run for every pull-request base ref. Python +Security retains its event-type filter and Runtime Quality retains its path +filter; only the base-branch filters are removed. Push and schedule behavior is +unchanged. The single permanent contract enumerates all four workflow files. + +## Failure scenes + +- A dependent PR targets a feature branch and edits scheduler Python: Python + Security and Runtime Quality must both be created. +- A PR does not touch Runtime Quality paths: its existing path filter still + prevents irrelevant work. +- Closing a Python PR: the existing event/action guards continue to apply. + +## Evidence and follow-up + +RED commit: `890bac2f69ff1a51f774ddf5d6c5d819afed4ac9`. +Fresh exact-head hosted runs and independent review remain required. + +## Reference + +GitHub. (2026). *Workflow syntax for GitHub Actions: on.pull_request.branches*. +https://docs.github.com/actions/reference/workflows-and-actions/workflow-syntax diff --git a/docs/doctoring/workflow-starting-mutation-credential-proof.md b/docs/doctoring/workflow-starting-mutation-credential-proof.md new file mode 100644 index 0000000000..251b664f1a --- /dev/null +++ b/docs/doctoring/workflow-starting-mutation-credential-proof.md @@ -0,0 +1,45 @@ +# Workflow-starting mutation credential proof + +Decision date: **2026-09-07** + +## Problem + +GitHub does not create a new workflow run for events generated by a workflow's +own `GITHUB_TOKEN`. A declared App or PAT source is therefore insufficient +authority: a missing secret can fall back to `github.token` while retaining an +allowlisted source label. Moving a PR head in that state creates the exact +chicken-and-egg condition the scheduler is intended to prevent—the new head +requires checks that its mutation credential cannot start. + +## Decision + +At the final mutation boundary, require all of the following: + +1. the declared source is workflow-starting; +2. the selected `GH_TOKEN` is present; +3. the workflow-token comparison value is present; and +4. the two token values differ. + +Any missing or identical evidence fails closed. The workflow supplies +`SCHEDULER_WORKFLOW_TOKEN` only to the scheduler mutation job. A recorded +withheld decision carries its own reason so later environment changes cannot +rewrite the operator explanation. + +## Failure scenes + +- A configured secret is empty and expression fallback selects + `github.token`: the mutation is withheld. +- Token comparison evidence is absent: the mutation is withheld. +- A decision is rendered after credentials rotate: the original reason remains + visible. + +## Evidence and follow-up + +The permanent RED regression is commit +`ebcc6715e68d6bd4dc78f1ce6c3e473a2dfef899`. Fresh exact-head hosted checks and +independent review remain required. + +## Reference + +GitHub. (2026). *Automatic token authentication*. +https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..a9e6a4f2be 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,63 @@ +## 2026-09-09 — Host-scoped stale-review revalidation (Proposed) + +- **Gap:** The scheduler cancelled central review runs with its central repository credential, but the immediately preceding live-run refresh still used the general target-repository read token. If that read token was denied while the central token remained valid, fail-closed preservation retained the stale run and could suppress current-head review dispatch. +- **Repair:** Route the exact active-run refresh through `run_github_actions_for_repository`, so central `.github` reads and cancellation share the dispatch credential while target repositories retain their Actions credential. Keep live PR/head validation on the target repository read boundary. Strengthen the stacked-PR security contract to reject both `branches` and `branches-ignore` filters. +- **Evidence:** The production-shaped credential-denial regression fails before the source change and passes after it; the existing host-scoped inventory/cancellation contract remains applicable. ContextualWisdomLab/.github PR #2040 owns delivery. +- **Status:** **Proposed** — focused and full exact-tree verification, hosted exact-head Checks, qualifying independent review, and protected merge remain required. + +## 2026-09-08 — CodeQL wake credential fallback (Proposed) + +- **Gap:** The run-wide wake chose the first nonempty credential before making any API call. A configured token that lacked Actions access to the target repository could therefore shadow a later working credential and leave a fully authenticated settlement unable to wake its exact required run. +- **Owner / evidence:** ContextualWisdomLab/.github PR #1902 integrating the valid wake delta identified on PR #2040; RED `be8702379171e7aa2f53d887326c524c20ee26a6`; executable denial fixture records the failed primary POST and successful fallback POST against the same exact run endpoint. +- **Repair:** Keep wake ownership in the one non-matrix settlement job, try `PR_REVIEW_MERGE_TOKEN`, then `OPENCODE_APPROVE_TOKEN`, then the native token only for a self-repository target. Use the same bounded chain for provenance reads and mutation, fail closed when it is exhausted, and do not transfer the scan job's repository-scoped App token across the job boundary. +- **Status:** **Proposed** — focused fallback and all 63 dispatch workflow contracts are GREEN locally; protected `main`, fresh exact-head hosted Checks, and qualifying independent review remain required. + +## 2026-09-08 — CodeQL cross-channel producer identity (Proposed) + +- **Gap:** Status receipt and status-less direct-run evidence were each authenticated, but the consumer selected them with shell short-circuiting. One complete status producer could therefore hide a different complete direct producer and bypass the global uniqueness boundary. +- **Owner / evidence:** ContextualWisdomLab/.github PR #1902; exact-head review comment `5583805210`; RED `060597a5691f49be23fb6a8da8e1b51731d729c3`; executable shard and coordinator fixtures with status producer `122` plus direct producer `123`. +- **Repair:** Enumerate both authenticated channels, union and deduplicate exact `(producer_run_id, state)` pairs, accept exactly one candidate, keep zero pending, and reject multiple or conflicting candidates with exact run-ID/state telemetry before credential acquisition or dispatch. +- **Status:** **Proposed** — focused cross-channel tests and all 138 CodeQL workflow contracts are GREEN locally; protected `main`, fresh exact-head hosted Checks, and qualifying independent review remain required. + +## 2026-09-08 — CodeQL dispatch payload cardinality (Proposed) + +- **Gap:** Exact-head CodeQL settlement could authenticate OIDC and the repository-scoped App token yet fail before scan creation because `repository_dispatch.client_payload` contained eleven top-level properties; GitHub permits at most ten. +- **Owner / evidence:** ContextualWisdomLab/.github PR #1902; run `34214980549`, job `102028015000` returned HTTP 422; RED `310e9e60926c5de31df629214bad8c55db610c82`, run `34217639402`, job `102033071652` reproduced the exact `11 <= 10` contract failure. +- **Repair:** Preserve repository, PR, live base/head, immutable producer, matrix and exact run/job authority while grouping `rerun_mode` and `required_jobs` into one `rerun_request` object. The receiver prefers the nested contract and accepts legacy fields only for in-flight compatibility. +- **Acceptance:** exact successor runtime-quality, security, SAST and real CodeQL dispatch/settlement must complete on the unchanged head; queued or predecessor evidence is not GREEN. + # Product and Technical Gap Baseline +## 2026-09-08 — CodeQL live-base recovery and status uniqueness (Proposed) + +- **Gap:** A protected-base advance while an unchanged PR head waited for a runner—or while its dispatched scan was already running—made the immutable attempt base stale. Shards rejected the mixed-base attempt correctly, but `rerun-failed-jobs` could not rerun the successful base-capture job or successful sibling shards. Separately, a predecessor receipt could claim a terminal state without an exactly matching Medium+ gate step, while multiple evidence-complete producers caused the coordinator to dispatch still more candidates into an already ambiguous set. +- **Owner / evidence:** ContextualWisdomLab/.github PR #1902; preserved RED commits `48baf18c11e4d942748b33cf7c94e15fe7fde7bb` and `b9245808fc498c877ba11562c6a0889983161b6c`; executable shard, coordinator, handler, gate-missing/duplicate/mismatch, pre-scan and post-scan base-advance, divergent-base, and receipt/direct-run ambiguity fixtures. +- **Action:** Capture one validated base before matrix expansion and revalidate it again in the trusted handler before wake. For a proven same-ref strict forward advance, bind recovery to the refreshed base and rerun the complete exact required workflow so capture and all shards refresh together; reject retargets, rewrites, divergence, and stale heads. Keep failed-job-only recovery for unchanged bases, bind every receipt state to exactly one matching gate plus SARIF artifact, and record exact run IDs/states then stop before credential acquisition or dispatch when multiple complete candidates remain. +- **Status:** **Proposed** — source and regression repair is on the owner branch; protected `main` integration, independent review, and exact-head hosted Checks remain required. + +## 2026-09-08 — CodeQL App receipt evidence (Proposed) + +- **Gap:** App-created terminal statuses returned before exact producer run, source, title, actor, unique successful `validate-dispatch`, language gate, SARIF, and artifact proof, so creator identity—or a scan launched from an unvalidated payload—could bypass the control-plane receipt boundary. +- **Owner / evidence:** ContextualWisdomLab/.github PR #1902; RED `e9589ed0f5685649fe4595a60c364676367c21d1` plus validation-boundary RED `acea6d9cfb1a867fc7ecc92f8df4108d94af3693`; executable shard and coordinator fixtures. +- **Action:** Admit known creators at the identity boundary, then require exactly one completed successful validation job and apply the common exact-dispatch evidence proof before consuming the status. +- **Status:** **Proposed** — published on the owner branch; protected `main`, exact-head Checks, and independent review remain required. + +## 2026-09-08 — CodeQL direct-evidence pagination (Proposed) + +- **Gap:** Exact central-run validation stopped after the first 100 producer jobs or artifacts in shard, coordinator, and settlement consumers, so valid later-page SARIF evidence could not release the required workflow. +- **Owner / evidence:** ContextualWisdomLab/.github PR #1902; RED `86898d3ecccdf8306d8dc42c8f9e7d5ee8dfbc3a`; five job/artifact collection pairs in the CodeQL owner workflows. +- **Action:** Use native GitHub pagination, stream each page's collection members, and reconstruct one object for the existing uniqueness and provenance checks. +- **Status:** **Proposed** — the owner branch contains the source repair; protected `main`, current-head hosted Checks, and independent review remain required. + + +## 2026-09-08 — CodeQL mixed-verdict settlement identity (Proposed) + +- **Gap:** When one CodeQL language already had an authenticated terminal receipt and another remained pending, the coordinator discarded the already-terminal language's failed-job identity. The trusted handler later uses GitHub's run-wide `rerun-failed-jobs` endpoint, so settlement could not prove a newer attempt for every failed language and the required workflow could remain circularly blocked. +- **Owner / evidence:** ContextualWisdomLab/.github PR #1902; RED `e25800f01c18ec8b28bd31b720478fc810cc4e92`; `.github/workflows/codeql-pr.yml`, `.github/workflows/codeql-scan-dispatch.yml`, and their executable contract tests. +- **Action:** Use authenticated receipts to skip dispatch only when every language is terminal. If any language remains pending, dispatch the complete exact failed-job language matrix and require a one-to-one matrix/job map because GitHub's run-wide `rerun-failed-jobs` wakes the complete failed set. +- **Status:** **Proposed** — source and regression repair is published on PR #1902; protected `main` integration, independent review, and current-head Checks remain required. + + 작성 기준일: **2026-08-26 10:35 KST** 대상: **ContextualWisdomLab/.github** 중앙 거버넌스·자동화 레포지터리와 이를 소비하는 naruon 생태계 현재 보호된 `main`: `826b92394c63deb6981c3a8d16a724d71f85a0d7` @@ -3039,6 +3097,47 @@ 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 a nested object +shadow independently supplied legacy ref/SHA values. The combined #2040 +contract validates the original JSON object, requires typed string fields, and +rejects non-equivalent nested/legacy identities. RED coverage pins numeric +schema, missing ref/SHA, and conflicting dual identity. + +Exact handler run `34235814716` exposed a second current-source gap: after both scan shards +correctly rejected a superseded base at privileged revalidation, unconditional publication +still wrote `error` to the unchanged current head. #2040 now requires successful second +revalidation and SARIF preservation before any status write, and verifies the returned creator. +Review then rejected the proposed head-only compatibility status because it can be reused after a +same-head base or required-run change. The selected successor integrates #1902's evidence-complete +producer and emits only the base-bound context, removing the migration cycle without dual authority. + +**Status:** Proposed; strict handler RED/GREEN contract prepared, 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 @@ -3174,6 +3273,52 @@ The earlier 1,719-run snapshot was incomplete. A repository-by-repository REST c 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. +### Item 41 follow-up: CodeQL dispatch settlement race — Proposed repair + +**Gap/evidence.** #1902 reduced its dispatch to GitHub's ten-property limit by +grouping `mode` and `required_jobs` under `rerun_request`, but protected handler +run `34220806323` rejected that valid envelope as a missing top-level job map. +Independently, handler run `34220757095` let the actions matrix shard wake the +shared required run and then rejected the Python shard's second job-level wake +with HTTP 403. Per-shard `actions: write` therefore violates the single-writer +boundary and cannot converge reliably. + +**Context Map / responsibility.** `.github`'s protected native handler owns +dispatch validation, scan evidence, and required-run settlement. The target +repository owns its PR and required workflow; it exposes only versioned payload +identity and GitHub's run APIs. #1902 remains the producer owner and may consume +the handler only after an ordinary protected merge; it must not read a branch +workflow or copy handler source. + +**Action/status.** #2040 is Proposed. It normalizes mutually exclusive legacy +and nested rerun envelopes, keeps matrix scans at `actions: read`, and assigns +one non-matrix `actions: write` owner. That owner revalidates the open PR, +unchanged base/head, exact required run and distinct job map, terminal handler +jobs, exact gate steps, and exact unexpired SARIF artifacts before one run-wide +mutation. A partial matrix paired with a larger job map is rejected; #1902 must +send the complete rerun map after this owner lands. Missing +or conflicting evidence, unrelated failed jobs, or exhausted credentials fail +closed. The combined contract also carries #2044's strict raw-JSON head envelope: +schema/ref/SHA must be typed strings and nested/legacy identities must agree. Producer +provenance is bound to the live synthetic PR merge commit and its ordered live base/head +parents, not to ancestry with the unrelated protected handler revision. Direct evidence instead +requires the handler run source to equal protected `.github/main` or remain its verified linear +ancestor; target run `34225089444` (`producer_source_sha=55a59cf5…`) is the RED evidence for +separating those identities. Merge and combined exact-head hosted GREEN +remain required before this gap can be marked delivered. + +The 2026-09-09 exact-head attempt exposed a remaining rollout cut: required run +`34249195529` created handler run `34249932036`, but protected main read the +nested-only request as `SUPPLIED_REQUIRED_JOBS: null`. #2040 now emits one +wire-compatible top-level `required_jobs` authority for `failed` mode and reserves +the nested envelope for the new `all` mode. This stays within GitHub's ten-property +limit and does not treat the failed predecessor as GREEN. + +Status publication is additionally gated by the privileged live-metadata recheck and successful +SARIF preservation. #1902's producer contract is integrated into the same successor, so the handler +writes only the base-bound context and rejects a response whose creator does not match the selected +credential boundary. No head-only migration bridge remains. + ## 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 @@ -3353,3 +3498,51 @@ queries the check-runs API at its own time, order-independently. The implementin 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.** + + +### Central Actions inventory credential routing + +- **Status:** Proposed +- **Owner:** `ContextualWisdomLab/.github` +- **Problem:** Central required-workflow inventory and cancellation inherited the + cross-repository Actions credential, so an exhausted App rate-limit bucket + could prevent discovery or cleanup of the current-head review run. +- **Action:** Route each Actions read/cancel operation by the repository hosting + the run. Use the central runner token only for + `ContextualWisdomLab/.github`; preserve the explicit target Actions token for + every other repository. +- **Evidence:** Historical owner PR + [#1231](https://github.com/ContextualWisdomLab/.github/pull/1231); RED commit + `8cc62ce8837e456dfac4f592bcbd0786a77e4b81`; fresh exact-head hosted checks + remain required before integration. + + +### Workflow-starting mutation credential proof + +- **Status:** Proposed +- **Owner:** `ContextualWisdomLab/.github` +- **Problem:** An allowlisted credential-source label could authorize a PR head + mutation even when the selected `GH_TOKEN` was missing or had fallen back to + the workflow `github.token`, which cannot trigger the required new + current-head workflow runs. +- **Action:** Require present, distinct selected-token and workflow-token + evidence at every head-mutation boundary; preserve the original rejection + reason for later operator guidance. +- **Evidence:** RED commit + `ebcc6715e68d6bd4dc78f1ce6c3e473a2dfef899`; fresh exact-head hosted checks + remain required before integration. + + +### Stacked Python and runtime review coverage + +- **Status:** Proposed +- **Owner:** `ContextualWisdomLab/.github` +- **Problem:** Python Security and Agent Review Runtime Quality CI filtered + `pull_request` events to default-like base branches, so a valid stacked PR + received Security/SAST/CodeQL but silently missed two owner checks. +- **Action:** Remove only the pull-request base filters and extend the existing + stacked-PR workflow regression to all four review workflows. +- **Evidence:** `ContextualWisdomLab/.github#2003` generated only three hosted + workflows at exact head `e2204eeb1ec2789ff791036140ba1672995d25f5`; + RED commit `890bac2f69ff1a51f774ddf5d6c5d819afed4ac9`; fresh exact-head + hosted checks remain required. diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 4df4dac3de..29f47772c7 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -455,34 +455,48 @@ def mutation_token_label() -> str: return labels.get(source, "workflow GH_TOKEN") -def head_mutation_credential_starts_workflows() -> bool: - """Return whether scheduler head mutations can start required workflow runs. +def head_mutation_credential_problem() -> str | None: + """Explain why the selected mutation credential cannot start workflow runs. GitHub never creates a new workflow run for an event produced with the - workflow ``GITHUB_TOKEN``, so a PR head moved with that credential can never - collect the current-head required checks that protected branches demand - (GitHub, 2025). - - References: - GitHub. (2025). *Automatic token authentication*. - https://docs.github.com/actions/security-for-github-actions/security-guides/automatic-token-authentication + workflow GITHUB_TOKEN, so a head moved with that credential cannot + collect protected-branch current-head checks. """ - return mutation_token_source() in WORKFLOW_STARTING_MUTATION_SOURCES - - -def non_triggering_head_mutation_reason(action: str) -> str: - """Explain why a head mutation is withheld for a non-triggering credential.""" source = mutation_token_source() if source == "github-token": - credential_reason = ( - "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" + return "the workflow GITHUB_TOKEN, whose head mutations never start new workflow runs" + if source not in WORKFLOW_STARTING_MUTATION_SOURCES: + return f"{mutation_token_label()} is not allowlisted as workflow-starting" + + selected_token = (os.environ.get("GH_TOKEN") or "").strip() + workflow_token = (os.environ.get("SCHEDULER_WORKFLOW_TOKEN") or "").strip() + if not selected_token: + return f"{mutation_token_label()} is missing and therefore not proven workflow-starting" + if not workflow_token: + return ( + "workflow GITHUB_TOKEN comparison evidence is missing, so the selected mutation " + "credential is not proven workflow-starting" ) - else: - credential_reason = ( - f"the {mutation_token_label()}, which is not allowlisted as workflow-starting" + if selected_token == workflow_token: + return ( + f"{mutation_token_label()} resolved to the workflow GITHUB_TOKEN, whose head " + "mutations never start new workflow runs" ) + return None + + +def head_mutation_credential_starts_workflows() -> bool: + """Return whether the actual scheduler mutation token can start workflow runs.""" + return head_mutation_credential_problem() is None + + +def non_triggering_head_mutation_reason(action: str) -> str: + """Explain why a head mutation is withheld for a non-triggering credential.""" + credential_reason = head_mutation_credential_problem() + if credential_reason is None: + raise RuntimeError("withheld-mutation messaging requires a non-triggering mutation credential") return ( - f"{action} withheld because the scheduler mutation credential is {credential_reason}, " + f"{action} withheld because {credential_reason}, " "so the moved head would stay permanently " "BLOCKED without current-head required checks; configure PR_REVIEW_MERGE_TOKEN, " "OPENCODE_APPROVE_TOKEN, or the OpenCode app token for the scheduler job" @@ -495,15 +509,10 @@ def require_workflow_starting_mutation_credential(action: str) -> None: raise RuntimeError(non_triggering_head_mutation_reason(action)) -def head_mutation_credential_guidance_text() -> tuple[str, str]: - """Return operator-facing summary and limit text for a withheld head mutation.""" - if mutation_token_source() == "github-token": - return ( - "The scheduler withheld a head mutation because the workflow GITHUB_TOKEN cannot start the required current-head workflow runs.", - "Moving the head with the workflow GITHUB_TOKEN would leave the PR permanently BLOCKED, so the scheduler waits instead.", - ) +def head_mutation_credential_guidance_text(withheld_reason: str) -> tuple[str, str]: + """Render operator guidance from the immutable credential decision.""" return ( - f"The scheduler withheld a head mutation because {mutation_token_label()} is not allowlisted as workflow-starting.", + f"The scheduler withheld a head mutation. Recorded decision: {withheld_reason}", "Moving the head is unsafe until the scheduler can prove that the selected credential starts the required current-head workflow runs.", ) @@ -654,7 +663,7 @@ def decision_guidance(decision: Decision) -> dict[str, Any] | None: ], } if parse_non_triggering_head_mutation_reason(decision.reason): - summary, automation_limit = head_mutation_credential_guidance_text() + summary, automation_limit = head_mutation_credential_guidance_text(decision.reason) return { "type": "head_mutation_credential_upgrade", "token": mutation_token_label(), @@ -826,6 +835,21 @@ def run_github_dispatch(args: Sequence[str], *, stdin: str | None = None) -> str return run_with_env(args, stdin=stdin, env=env) +def run_github_actions_for_repository( + repo: str, + args: Sequence[str], + *, + stdin: str | None = None, +) -> str: + """Run an Actions command with the credential scoped to its host repository.""" + central_repo = ( + os.environ.get("SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY") or "" + ).strip() + if central_repo and repo.casefold() == central_repo.casefold(): + return run_github_dispatch(args, stdin=stdin) + return run_github_actions(args, stdin=stdin) + + def split_repo(repo: str) -> tuple[str, str]: """Split an owner/name repository string into owner and repository name.""" try: @@ -3162,7 +3186,7 @@ def active_workflow_runs( args += ["-f", f"created={created}"] if head_sha: args += ["-f", f"head_sha={head_sha}"] - payload = json.loads(run_github_actions(args)) + payload = json.loads(run_github_actions_for_repository(repo, args)) pages = payload if isinstance(payload, list) else [payload] for page in pages: runs.extend(page.get("workflow_runs") or []) @@ -3411,14 +3435,15 @@ def force_cancel_workflow_runs(repo: str, run_ids: Sequence[str]) -> dict[str, s def cancel_one(run_id: str) -> tuple[str, str | None]: """Return one run id and its bounded GitHub cancellation error, if any.""" try: - run_github_actions( + run_github_actions_for_repository( + repo, [ "gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel", - ] + ], ) except RuntimeError as exc: return run_id, str(exc).replace("\n", "; ")[:600] @@ -3459,8 +3484,11 @@ def _fresh_open_pr_for_cancellation(repo: str, number: int) -> dict[str, Any]: def _fresh_active_run_for_cancellation(run_repo: str, run_id: str) -> dict[str, Any]: - """Return fresh active workflow-run evidence immediately before cancellation.""" - payload = gh_api_json(f"repos/{run_repo}/actions/runs/{run_id}") + """Return fresh active run evidence with its repository-scoped Actions token.""" + path = f"repos/{run_repo}/actions/runs/{run_id}" + payload = json.loads( + run_github_actions_for_repository(run_repo, ["gh", "api", path]) + ) if not isinstance(payload, dict) or str(payload.get("status") or "").lower() not in { "queued", "in_progress", @@ -5197,7 +5225,7 @@ def head_mutation_credential_upgrade_summary(decisions: list[Decision]) -> list[ waits = [decision for decision in decisions if parse_non_triggering_head_mutation_reason(decision.reason)] if not waits: return [] - summary, automation_limit = head_mutation_credential_guidance_text() + summary, automation_limit = head_mutation_credential_guidance_text(waits[0].reason) lines = ["", "### Head mutation withheld", "", summary, automation_limit] lines.extend( [ @@ -5216,6 +5244,8 @@ def parse_non_triggering_head_mutation_reason(reason: str) -> bool: return ( "whose head mutations never start new workflow runs" in reason or "which is not allowlisted as workflow-starting" in reason + or "is not allowlisted as workflow-starting" in reason + or "not proven workflow-starting" in reason ) @@ -5413,16 +5443,28 @@ def summarize_action_error(exc: RuntimeError) -> str: @contextlib.contextmanager def declared_mutation_token_source(source: str) -> Iterator[None]: - """Declare a scheduler mutation credential source for the enclosed block.""" - previous = os.environ.get("SCHEDULER_MUTATION_TOKEN_SOURCE") + """Declare coherent synthetic mutation-token evidence for offline self-tests.""" + keys = ( + "SCHEDULER_MUTATION_TOKEN_SOURCE", + "GH_TOKEN", + "SCHEDULER_WORKFLOW_TOKEN", + ) + previous = {key: os.environ.get(key) for key in keys} os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = source + os.environ["SCHEDULER_WORKFLOW_TOKEN"] = "self-test-workflow-token" + os.environ["GH_TOKEN"] = ( + "self-test-workflow-token" + if source == "github-token" + else "self-test-selected-mutation-token" + ) try: yield finally: - if previous is None: - os.environ.pop("SCHEDULER_MUTATION_TOKEN_SOURCE", None) - else: - os.environ["SCHEDULER_MUTATION_TOKEN_SOURCE"] = previous + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value def self_test() -> None: diff --git a/tests/test_codeql_pr_rerun_recovery_contract.py b/tests/test_codeql_pr_rerun_recovery_contract.py new file mode 100644 index 0000000000..3d1b61653d --- /dev/null +++ b/tests/test_codeql_pr_rerun_recovery_contract.py @@ -0,0 +1,54 @@ +"""Regression for CodeQL reruns whose earlier attempt never dispatched.""" + +from __future__ import annotations + +from pathlib import Path + +from tests.test_codeql_pr_workflow_contract import WORKFLOW_PATH, _run_coordinator +from tests.test_opencode_workflow_shell_syntax import _extract_run_block + + +DISPATCH_STEP_NAME = "Dispatch current-head CodeQL scan" + + +def test_rerun_without_authenticated_verdict_can_redispatch(tmp_path: Path) -> None: + """A later attempt may dispatch when only an old-base verdict exists.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + coordinator = workflow.split(" dispatch-current-head:\n", 1)[1] + admission = coordinator.split("\n runs-on:", 1)[0] + + assert "github.run_attempt == 1" not in admission + + result, post_log, post_body = _run_coordinator( + tmp_path, + statuses=[ + { + "context": f"codeql-dispatch/python/{'c' * 40}", + "description": f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99", + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/122" + ), + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + }, + ], + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/.github/dispatches" + ] + assert '"event_type":"codeql-scan"' in post_body.read_text(encoding="utf-8") + + +def test_status_lookup_paginates_complete_history_before_redispatch() -> None: + """Recovery inspects every status page before treating a verdict as absent.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + script = _extract_run_block(workflow, DISPATCH_STEP_NAME) + + assert ( + 'gh api --paginate --slurp ' + '"repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses?per_page=100"' + in script + ) + assert ".[][]" in script diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index dc67eef258..0e7ee4b3de 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -6,6 +6,8 @@ import sys from pathlib import Path +import pytest + from tests.test_opencode_workflow_shell_syntax import _extract_run_block @@ -55,8 +57,16 @@ def test_codeql_pr_workflow_structure() -> None: assert "repos/ContextualWisdomLab/.github/dispatches" in workflow # Reads the authenticated context codeql-scan-dispatch.yml publishes; it # never publishes that status from the required workflow. - assert '--arg ctx "codeql-dispatch/${LANGUAGE}"' in workflow + assert 'receipt_context="codeql-dispatch/${LANGUAGE}/${PR_BASE_SHA}"' in workflow + assert '--arg ctx "$receipt_context"' in workflow assert "commits/${PR_HEAD_SHA}/statuses" in workflow + assert workflow.count( + 'protected_branch="$(gh api "repos/ContextualWisdomLab/.github/branches/main"' + ) == 2 + assert workflow.count( + 'compare/${handler_source_sha}...${protected_tip}' + ) == 2 + assert 'compare/${PRODUCER_SOURCE_SHA}...${handler_source_sha}' not in workflow def test_codeql_pr_shards_do_not_dispatch_and_coordinator_sends_the_full_matrix_once() -> None: @@ -87,28 +97,8 @@ 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] - coordinator_if = coordinator.split("\n runs-on:", 1)[0] - assert "github.run_attempt == 1" not in coordinator_if + assert "github.run_attempt == 1" not in coordinator.split("\n runs-on:", 1)[0] 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 @@ -118,6 +108,32 @@ def test_codeql_coordinator_dispatches_later_attempts_when_no_terminal_verdict() assert "CodeQL compatibility analysis (" in coordinator +def test_codeql_receipt_provenance_binds_the_exact_required_run() -> None: + """A same-head/base receipt from another required run is not reusable.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + + expected = ( + 'expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}' + '@${PR_HEAD_SHA}/${PR_BASE_SHA}/${REQUIRED_RUN_ID}/${PRODUCER_SOURCE_SHA}"' + ) + assert workflow.count(expected) == 4 + assert workflow.count("REQUIRED_RUN_ID: ${{ github.run_id }}") == 2 + assert workflow.count("PRODUCER_SOURCE_SHA: ${{ github.workflow_sha }}") == 2 + assert "producer_source_sha:$producer_source_sha" in workflow + + +def test_codeql_pr_captures_one_live_base_for_the_whole_attempt() -> None: + """Every matrix shard and its coordinator use one captured attempt base.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + + assert "id: capture-base" in workflow + assert "base_sha: ${{ steps.capture-base.outputs.base_sha }}" in workflow + assert workflow.count( + "PR_BASE_SHA: ${{ needs.detect-languages.outputs.base_sha }}" + ) == 2 + assert workflow.count('[ "${live_base_sha,,}" != "${PR_BASE_SHA,,}" ]') == 2 + + RUN_BLOCK_STEP_NAMES = ( "Read current-head CodeQL dispatch verdict", "Release runner or enforce current-head CodeQL verdict", @@ -150,47 +166,45 @@ 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( +def _codeql_status( + state: str, *, - title: str, - run_id: int = 34173910106, -) -> dict: - """Return one completed central CodeQL dispatch workflow-run fixture.""" + creator: str = "opencode-agent[bot]", + base_sha: str = "a" * 40, + head_sha: str = "b" * 40, + producer_source_sha: str = "c" * 40, + producer_run_id: int = 123, +) -> dict[str, object]: + """Return one provenance-bound CodeQL dispatch status fixture.""" return { - "id": run_id, - "event": "repository_dispatch", - "path": ".github/workflows/codeql-scan-dispatch.yml", - "status": "completed", - "display_title": title, - "name": title, + "context": f"codeql-dispatch/python/{base_sha}", + "description": ( + f"cwl1;h={head_sha};w=codeql-scan-dispatch;r=42;" + f"s={producer_source_sha}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/" + f"{producer_run_id}" + ), + "state": state, + "creator": {"login": creator}, } def _run_verdict_read( - tmp_path: Path, - statuses: list[dict], - *, - dispatch_runs: dict | list[dict] | None = None, - dispatch_jobs: dict | list[dict] | None = None, - run_attempt: str = "2", + tmp_path: Path, statuses: list[dict], *, second_page: list[dict] | None = None, + base: dict | None = None, env_overrides: dict[str, str] | None = None, + expect_dispatch_failure: bool = False, + target_repository: str = "ContextualWisdomLab/naruon", + producer_run: dict[str, object] | None = None, + producer_runs: list[dict[str, object]] | None = None, + producer_jobs: dict[str, object] | list[dict[str, object]] | None = None, + producer_artifacts: dict[str, object] | list[dict[str, object]] | None = None, + predecessor_jobs: dict[str, object] | None = None, + predecessor_artifacts: dict[str, object] | None = None, + producer_state: str = "success", ) -> tuple[subprocess.CompletedProcess[str], subprocess.CompletedProcess[str]]: """Execute the real one-shot status read and verdict enforcement blocks.""" bash = shutil.which("bash") @@ -201,28 +215,103 @@ 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 = _TEST_HEAD_SHA + head_sha = "b" * 40 live_pr = { - "head": {"sha": head_sha}, - "base": {"sha": _TEST_BASE_SHA}, - "state": "open", + "head": {"sha": head_sha}, "state": "open", + "base": base if base is not None else { + "repo": {"full_name": target_repository}, + "ref": "main", "sha": "a" * 40, + }, + } + live_base_sha = live_pr["base"].get("sha", "") + producer_run = producer_run or { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "status": "in_progress", + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + f"CodeQL Scan Dispatch {target_repository}#42@{head_sha}/" + f"{live_base_sha}/42/{'c' * 40}" + ), } + if producer_jobs is None: + producer_jobs = { + "jobs": [ + { + "name": "validate-dispatch", + "status": "completed", + "conclusion": "success", + }, + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": producer_state, + "run_attempt": 1, + "steps": [ + { + "name": "Enforce CodeQL Medium+ SARIF gate", + "conclusion": producer_state, + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, + ], + }, + ] + } + if producer_artifacts is None: + producer_artifacts = { + "total_count": 1, + "artifacts": [{ + "name": "codeql-dispatch-python-123-1", + "expired": False, + }], + } + incomplete_predecessor = dict(producer_run) + incomplete_predecessor["id"] = 122 + producer_runs = producer_runs if producer_runs is not None else [producer_run] fake_bin = tmp_path / "bin" - fake_bin.mkdir() + fake_bin.mkdir(parents=True) fake_gh = fake_bin / "gh" fake_gh.write_text( "#!/usr/bin/env bash\n" "set -euo pipefail\n" + 'printf "%s\\n" "$*" >>"$FAKE_CALL_LOG"\n' 'test "$1" = api\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", + 'if [ "$#" = 2 ] && [ "$2" = "repos/${TARGET_REPOSITORY}/pulls/42" ]; then\n' + " printf '%s\\n' \"$FAKE_PULL_JSON\"\n" + 'elif [ "$#" = 2 ] && [ "$2" = "repos/ContextualWisdomLab/.github/branches/main" ]; then\n' + " printf '%s\\n' \"$FAKE_HANDLER_BRANCH_JSON\"\n" + 'elif [ "$#" = 2 ] && [[ "$2" == repos/ContextualWisdomLab/.github/compare/* ]]; then\n' + " printf '%s\\n' \"$FAKE_SOURCE_COMPARE_JSON\"\n" + 'elif [ "$#" = 4 ] && [ "$2" = --paginate ] && [ "$3" = --slurp ] &&\n' + ' [ "$4" = "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses?per_page=100" ]; then\n' + " printf '%s\\n' \"$FAKE_STATUSES_JSON\"\n" + 'elif [ "$#" = 4 ] && [ "$2" = --paginate ] && [ "$3" = --slurp ] &&\n' + ' [ "$4" = "repos/ContextualWisdomLab/.github/actions/workflows/codeql-scan-dispatch.yml/runs?event=repository_dispatch&per_page=100" ]; then\n' + " printf '%s\\n' \"$FAKE_PRODUCER_RUNS_JSON\"\n" + 'elif [ "$#" = 2 ] && [ "$2" = "repos/ContextualWisdomLab/.github/actions/runs/123" ]; then\n' + " printf '%s\\n' \"$FAKE_PRODUCER_RUN_JSON\"\n" + 'elif [ "$#" = 2 ] && [ "$2" = "repos/ContextualWisdomLab/.github/actions/runs/122" ]; then\n' + " printf '%s\\n' \"$FAKE_PREDECESSOR_RUN_JSON\"\n" + 'elif [ "$#" = 4 ] && [ "$2" = --paginate ] && [ "$3" = --slurp ] && [ "$4" = "repos/ContextualWisdomLab/.github/actions/runs/123/jobs?filter=latest&per_page=100" ]; then\n' + " printf '%s\\n' \"$FAKE_PRODUCER_JOBS_JSON\"\n" + 'elif [ "$#" = 4 ] && [ "$2" = --paginate ] && [ "$3" = --slurp ] && [ "$4" = "repos/ContextualWisdomLab/.github/actions/runs/123/artifacts?name=codeql-dispatch-python-123-1&per_page=100" ]; then\n' + " printf '%s\\n' \"$FAKE_PRODUCER_ARTIFACTS_JSON\"\n" + 'elif [ "$#" = 4 ] && [ "$2" = --paginate ] && [ "$3" = --slurp ] && [[ "$4" == repos/ContextualWisdomLab/.github/actions/runs/122/jobs* ]]; then\n' + " printf '%s\\n' \"$FAKE_PREDECESSOR_JOBS_JSON\"\n" + 'elif [ "$#" = 4 ] && [ "$2" = --paginate ] && [ "$3" = --slurp ] && [[ "$4" == repos/ContextualWisdomLab/.github/actions/runs/122/artifacts* ]]; then\n' + " printf '%s\\n' \"$FAKE_PREDECESSOR_ARTIFACTS_JSON\"\n" + "else\n" + " exit 1\n" + "fi\n", encoding="utf-8", ) fake_gh.chmod(0o755) @@ -232,50 +321,75 @@ def _run_verdict_read( **os.environ, "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_STATUSES_JSON": json.dumps( + [statuses] if second_page is None else [statuses, second_page] + ), + "FAKE_PRODUCER_RUN_JSON": json.dumps(producer_run), + "FAKE_HANDLER_BRANCH_JSON": json.dumps( + { + "name": "main", + "protected": True, + "commit": {"sha": producer_run["head_sha"]}, + } + ), + "FAKE_PREDECESSOR_RUN_JSON": json.dumps(incomplete_predecessor), + "FAKE_PREDECESSOR_JOBS_JSON": json.dumps( + [predecessor_jobs or {"jobs": []}] ), - "FAKE_DISPATCH_JOBS_JSON": json.dumps( - dispatch_jobs - if isinstance(dispatch_jobs, list) - else [dispatch_jobs if dispatch_jobs is not None else {"jobs": []}] + "FAKE_PREDECESSOR_ARTIFACTS_JSON": json.dumps( + [predecessor_artifacts or {"artifacts": []}] + ), + "FAKE_PRODUCER_RUNS_JSON": json.dumps([{"workflow_runs": producer_runs}]), + "FAKE_PRODUCER_JOBS_JSON": json.dumps( + producer_jobs if isinstance(producer_jobs, list) else [producer_jobs] + ), + "FAKE_PRODUCER_ARTIFACTS_JSON": json.dumps( + producer_artifacts if isinstance(producer_artifacts, list) + else [producer_artifacts] + ), + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + { + "status": "identical", + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "c" * 40}, + } ), "GH_TOKEN": "fake-token", - "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "FAKE_CALL_LOG": str(tmp_path / "gh-calls"), + "TARGET_REPOSITORY": target_repository, + "GITHUB_REPOSITORY": target_repository, "PR_NUMBER": "42", "PR_HEAD_SHA": head_sha, "LANGUAGE": "python", "BUILD_MODE": "none", - "BASE_REF": "main", - "BASE_SHA": _TEST_BASE_SHA, - "HEAD_REF": "feature", - "RUN_ATTEMPT": run_attempt, - "REQUIRED_RUN_ID": _TEST_REQUIRED_RUN_ID, + "PR_BASE_REF": "main", + "PR_BASE_SHA": "a" * 40, + "PR_HEAD_REF": "feature", + "RUN_ATTEMPT": "2", + "REQUIRED_RUN_ID": "42", "REQUIRED_JOB_ID": "43", + "PRODUCER_SOURCE_SHA": "c" * 40, "GITHUB_OUTPUT": str(output), + **(env_overrides or {}), } dispatch_result = subprocess.run( [bash], input=dispatch_script, text=True, capture_output=True, check=False, env=dispatch_env, timeout=60, ) - 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="" + if expect_dispatch_failure: + assert dispatch_result.returncode != 0, dispatch_result.stdout + else: + assert dispatch_result.returncode == 0, dispatch_result.stderr + output_values = dict( + line.split("=", 1) for line in ( + output.read_text(encoding="utf-8").splitlines() if output.exists() else [] ) + ) verdict_env = { **os.environ, "LANGUAGE": "python", - "DISPATCH_OUTCOME": "success", - "VERDICT_STATE": output_values["verdict"], + "DISPATCH_OUTCOME": "success" if dispatch_result.returncode == 0 else "failure", + "VERDICT_STATE": output_values.get("verdict", ""), } verdict_result = subprocess.run( [bash], input=verdict_script, text=True, capture_output=True, check=False, @@ -284,6 +398,116 @@ def _run_verdict_read( return dispatch_result, verdict_result +@pytest.mark.parametrize("field,value", [ + ("repo", {"full_name": "ContextualWisdomLab/other"}), + ("repo", {}), ("ref", "other"), ("ref", ""), ("ref", 42), + ("sha", ""), ("sha", "not-a-sha"), +]) +def test_codeql_terminal_rejects_invalid_live_base_before_status_read( + tmp_path: Path, field: str, value: object, +) -> None: + """A genuine old success cannot excuse malformed live base identity.""" + base = {"repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", "sha": "a" * 40} + base[field] = value + dispatch, verdict = _run_verdict_read(tmp_path, [ + {"context": "codeql-dispatch/python", "state": "success", + "creator": {"login": "opencode-agent[bot]"}}, + ], base=base, expect_dispatch_failure=True) + assert "base" in dispatch.stdout.lower() + assert verdict.returncode == 1 + assert (tmp_path / "gh-calls").read_text().splitlines() == [ + "api repos/ContextualWisdomLab/naruon/pulls/42" + ] + + +def test_codeql_terminal_uses_the_shared_attempt_base_before_runner_admission( + tmp_path: Path, +) -> None: + """A shard accepts the live base captured once by its upstream attempt.""" + live_base_sha = "d" * 40 + dispatch, verdict = _run_verdict_read( + tmp_path, + [_codeql_status("success", base_sha=live_base_sha)], + base={ + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", + "sha": live_base_sha, + }, + env_overrides={"PR_BASE_SHA": live_base_sha}, + ) + + assert dispatch.returncode == 0, dispatch.stderr + dispatch.stdout + assert verdict.returncode == 0, verdict.stderr + verdict.stdout + + +def test_codeql_terminal_rejects_base_that_advanced_after_attempt_capture( + tmp_path: Path, +) -> None: + """A shard fails closed when live base moves after the shared capture.""" + dispatch, verdict = _run_verdict_read( + tmp_path, + [], + base={ + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", + "sha": "d" * 40, + }, + expect_dispatch_failure=True, + ) + + assert "attempt base" in dispatch.stdout.lower() + assert verdict.returncode == 1 + + +@pytest.mark.parametrize("field,value", [("PR_BASE_REF", "")]) +def test_codeql_terminal_rejects_missing_event_base_ref( + tmp_path: Path, field: str, value: str, +) -> None: + _dispatch, verdict = _run_verdict_read(tmp_path, [], + env_overrides={field: value}, expect_dispatch_failure=True) + assert verdict.returncode == 1 + assert (tmp_path / "gh-calls").read_text().splitlines() == [ + "api repos/ContextualWisdomLab/naruon/pulls/42" + ] + + +def test_codeql_shard_rejects_per_shard_rebind_to_a_newer_live_base( + tmp_path: Path, +) -> None: + """A shard cannot adopt a base newer than the attempt-wide captured base.""" + live_base_sha = "d" * 40 + producer_run = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + live_base_sha + "/42/" + "c" * 40 + ), + } + dispatch, verdict = _run_verdict_read( + tmp_path, + [_codeql_status("success", base_sha=live_base_sha)], + base={ + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", + "sha": live_base_sha, + }, + producer_run=producer_run, + expect_dispatch_failure=True, + ) + + assert dispatch.returncode == 1 + assert "attempt base" in dispatch.stdout.lower() + assert verdict.returncode == 1 + + def test_codeql_pr_one_shot_read_ignores_status_forged_by_non_opencode_creator(tmp_path: Path) -> None: """A PR-forged 'codeql-dispatch/: success' status must not stand in for the real verdict. @@ -299,13 +523,10 @@ def test_codeql_pr_one_shot_read_ignores_status_forged_by_non_opencode_creator(t dispatch_result, verdict_result = _run_verdict_read( tmp_path, statuses=[ - {"context": "codeql-dispatch/python", "state": "success", "creator": {"login": "attacker"}}, - { - "context": "codeql-dispatch/python", - "state": "failure", - "creator": {"login": "opencode-agent[bot]"}, - }, + _codeql_status("success", creator="attacker"), + _codeql_status("failure"), ], + producer_state="failure", ) assert dispatch_result.returncode == 0, dispatch_result.stderr assert verdict_result.returncode == 1, verdict_result.stderr @@ -313,15 +534,11 @@ def test_codeql_pr_one_shot_read_ignores_status_forged_by_non_opencode_creator(t def test_codeql_pr_one_shot_read_accepts_the_opencode_agent_creator(tmp_path: Path) -> None: - """The legitimate handler's own success status is accepted once creator identity matches.""" + """A legitimate App receipt is accepted with complete producer evidence.""" dispatch_result, verdict_result = _run_verdict_read( tmp_path, statuses=[ - { - "context": "codeql-dispatch/python", - "state": "success", - "creator": {"login": "opencode-agent[bot]"}, - } + _codeql_status("success") ], ) assert dispatch_result.returncode == 0, dispatch_result.stderr @@ -329,165 +546,847 @@ 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( +def test_codeql_pr_accepts_self_repository_github_actions_receipt_only_from_exact_dispatch_run( 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) + """The self-repository token fallback is trusted only through exact run provenance.""" 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", - } - ] - }, + statuses=[_codeql_status("success", creator="github-actions[bot]")], + target_repository="ContextualWisdomLab/.github", ) + 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( +def test_codeql_pr_accepts_producer_source_distinct_from_target_base( 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) + """Central workflow source and target PR base are independent identities.""" + producer_run = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/.github#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "c" * 40 + ), + } 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", - } - ] - }, - ], + statuses=[_codeql_status("success", creator="github-actions[bot]")], + target_repository="ContextualWisdomLab/.github", + producer_run=producer_run, + env_overrides={"PRODUCER_SOURCE_SHA": "c" * 40}, ) 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( +def test_codeql_pr_accepts_direct_evidence_from_descendant_handler_source( tmp_path: Path, ) -> None: - """Same head and language after a base retarget must not reuse the prior scan. + """A handler on protected main may descend from the target PR base. - 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. + The producer source is the target PR's synthetic merge revision. A + repository_dispatch handler runs from central protected main, so its + ancestry must be proven against that protected branch, not the synthetic + merge or target-repository base. """ - stale_title = _dispatch_scan_title(base_sha="c" * 40) - dispatch_result, _verdict_result = _run_verdict_read( + producer_run = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "d" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "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": [ + producer_run=producer_run, + env_overrides={ + "FAKE_HANDLER_BRANCH_JSON": json.dumps( { - "name": "CodeQL dispatch scan (python)", - "conclusion": "success", + "name": "main", + "protected": True, + "commit": {"sha": "e" * 40}, } - ] + ), + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + { + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "base_commit": {"sha": "d" * 40}, + "merge_base_commit": {"sha": "d" * 40}, + } + ) }, ) - 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 + assert dispatch_result.returncode == 0, dispatch_result.stderr + dispatch_result.stdout + assert verdict_result.returncode == 0, verdict_result.stderr + verdict_result.stdout + assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout -def test_codeql_pr_rejects_completed_dispatch_scan_from_a_different_required_run( +def test_codeql_pr_rejects_handler_source_outside_protected_main( tmp_path: Path, ) -> None: - """A same-PR/head/language scan for another required run cannot wake this shard. + """A named main branch without protection cannot authorize handler evidence.""" + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + expect_dispatch_failure=True, + env_overrides={ + "FAKE_HANDLER_BRANCH_JSON": json.dumps( + { + "name": "main", + "protected": False, + "commit": {"sha": "c" * 40}, + } + ) + }, + ) - 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( + assert dispatch_result.returncode == 1 + assert "authenticated terminal verdict" in dispatch_result.stdout + assert verdict_result.returncode == 1 + + +def test_codeql_pr_rejects_divergent_protected_handler_source( + tmp_path: Path, +) -> None: + """A sibling or rewritten source cannot borrow protected-main identity.""" + producer_run = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "d" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "c" * 40 + ), + } + dispatch_result, verdict_result = _run_verdict_read( tmp_path, statuses=[], - dispatch_runs={ - "workflow_runs": [_completed_dispatch_run(title=other_run_title)] + producer_run=producer_run, + expect_dispatch_failure=True, + env_overrides={ + "FAKE_HANDLER_BRANCH_JSON": json.dumps( + { + "name": "main", + "protected": True, + "commit": {"sha": "e" * 40}, + } + ), + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + { + "status": "diverged", + "ahead_by": 1, + "behind_by": 1, + "base_commit": {"sha": "e" * 40}, + "merge_base_commit": {"sha": "f" * 40}, + } + ), }, - dispatch_jobs={ + ) + + assert dispatch_result.returncode == 1 + assert "authenticated terminal verdict" in dispatch_result.stdout + assert verdict_result.returncode == 1 + + +def test_codeql_pr_reads_direct_evidence_on_later_job_and_artifact_pages( + tmp_path: Path, +) -> None: + """Direct evidence must not stop at the first jobs or artifacts page.""" + producer_jobs = [ + { + "jobs": [ + { + "name": "validate-dispatch", + "status": "completed", + "conclusion": "success", + } + ] + }, + { "jobs": [ { "name": "CodeQL dispatch scan (python)", + "status": "completed", "conclusion": "success", + "run_attempt": 1, + "steps": [ + { + "name": "Enforce CodeQL Medium+ SARIF gate", + "conclusion": "success", + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, + ], } ] }, + ] + producer_artifacts = [ + {"artifacts": []}, + { + "artifacts": [ + {"name": "codeql-dispatch-python-123-1", "expired": False} + ] + }, + ] + + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, ) - 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 + assert dispatch_result.returncode == 0, dispatch_result.stderr + dispatch_result.stdout + assert verdict_result.returncode == 0, verdict_result.stderr + verdict_result.stdout + assert "Current-head CodeQL dispatch verdict for python: success." in verdict_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] +def test_codeql_pr_selects_unique_complete_run_after_duplicate_title_predecessor( + tmp_path: Path, +) -> None: + """An incomplete same-title predecessor cannot hide one complete successor.""" + complete = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "c" * 40 + ), + } + incomplete = dict(complete) + incomplete["id"] = 122 - 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 + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + producer_run=complete, + producer_runs=[incomplete, complete], + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + dispatch_result.stdout + assert verdict_result.returncode == 0, verdict_result.stderr + verdict_result.stdout + assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout -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( - encoding="utf-8" - ) - refs = set( - re.findall( - r"github/codeql-action/(?:init|analyze|upload-sarif)@([0-9a-f]{40})", - workflow, - ) - ) - assert len(refs) == 1, f"scheduled-security-scan.yml mixes CodeQL action refs: {sorted(refs)}" +def test_codeql_pr_rejects_two_complete_duplicate_title_runs( + tmp_path: Path, +) -> None: + """Two evidence-complete same-title runs remain ambiguous and fail closed.""" + complete = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "c" * 40 + ), + } + second = dict(complete) + second["id"] = 122 + predecessor_jobs = { + "jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ] + } + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + producer_run=complete, + producer_runs=[second, complete], + predecessor_jobs=predecessor_jobs, + predecessor_artifacts={ + "artifacts": [ + {"name": "codeql-dispatch-python-122-1", "expired": False} + ] + }, + expect_dispatch_failure=True, + ) -def test_codeql_shard_releases_runner_and_reads_exact_head_verdict() -> None: - workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - shard = workflow.split(" analyze-head:\n", 1)[1].split( - " dispatch-current-head:\n", 1 - )[0] + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + + +def test_codeql_pr_rejects_two_evidence_complete_status_receipts( + tmp_path: Path, +) -> None: + """Conflicting complete status producers cannot win by response order.""" + complete = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "c" * 40 + ), + } + second = dict(complete) + second["id"] = 122 + second_status = _codeql_status("success") + second_status["target_url"] = ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/122" + ) + predecessor_jobs = { + "jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ] + } + + _run_verdict_read( + tmp_path, + [_codeql_status("success"), second_status], + producer_run=complete, + producer_runs=[second, complete], + predecessor_jobs=predecessor_jobs, + predecessor_artifacts={ + "artifacts": [ + {"name": "codeql-dispatch-python-122-1", "expired": False} + ] + }, + expect_dispatch_failure=True, + ) + + +def test_codeql_pr_rejects_cross_channel_complete_producers( + tmp_path: Path, +) -> None: + """A status receipt cannot hide a distinct complete direct producer.""" + receipt = _codeql_status("success", producer_run_id=122) + predecessor_jobs = { + "jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ] + } + + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + [receipt], + predecessor_jobs=predecessor_jobs, + predecessor_artifacts={ + "artifacts": [ + {"name": "codeql-dispatch-python-122-1", "expired": False} + ] + }, + expect_dispatch_failure=True, + ) + + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + assert '"run_id":122' in dispatch_result.stderr + assert '"run_id":123' in dispatch_result.stderr + + +def test_codeql_pr_app_receipt_requires_exact_dispatch_evidence( + tmp_path: Path, +) -> None: + """App receipts with wrong, incomplete, or missing evidence fail closed.""" + valid_run: dict[str, object] = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "c" * 40 + ), + } + valid_jobs = { + "jobs": [ + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + } + ] + } + wrong_workflow = dict(valid_run) + wrong_workflow["path"] = ".github/workflows/other.yml" + in_progress = json.loads(json.dumps(valid_jobs)) + in_progress["jobs"][0]["status"] = "in_progress" + + for name, run, jobs, artifacts in ( + ("wrong-workflow", wrong_workflow, valid_jobs, None), + ("in-progress", valid_run, in_progress, None), + ("missing-artifact", valid_run, valid_jobs, {"artifacts": []}), + ): + dispatch_result, verdict_result = _run_verdict_read( + tmp_path / name, + statuses=[_codeql_status("success")], + producer_run=run, + producer_jobs=jobs, + producer_artifacts=artifacts, + expect_dispatch_failure=True, + ) + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + assert "without an authenticated terminal verdict" in dispatch_result.stdout + + +@pytest.mark.parametrize( + "validation_jobs", + [ + [], + [{"name": "validate-dispatch", "status": "completed", "conclusion": "failure"}], + [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + ], + ], +) +def test_codeql_pr_app_receipt_requires_one_successful_validation_job( + tmp_path: Path, validation_jobs: list[dict[str, str]], +) -> None: + """An App status cannot bypass the dispatch payload validation boundary.""" + producer_jobs = { + "jobs": [ + *validation_jobs, + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + { + "name": "Enforce CodeQL Medium+ SARIF gate", + "conclusion": "success", + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, + ], + }, + ] + } + + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[_codeql_status("success")], + producer_jobs=producer_jobs, + expect_dispatch_failure=True, + ) + + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + assert "without an authenticated terminal verdict" in dispatch_result.stdout + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("event", "pull_request"), + ("path", ".github/workflows/other.yml"), + ("repository", {"full_name": "ContextualWisdomLab/other"}), + ("actor", {"login": "attacker"}), + ("triggering_actor", {"login": "attacker"}), + ], +) +def test_codeql_pr_rejects_app_receipt_without_exact_run_metadata( + tmp_path: Path, field: str, value: object, +) -> None: + """OpenCode App identity cannot replace immutable handler-run metadata.""" + producer_run: dict[str, object] = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/42/" + "c" * 40 + ), + } + producer_run[field] = value + + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[_codeql_status("success")], + producer_run=producer_run, + producer_runs=[], + expect_dispatch_failure=True, + ) + + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + + +def test_codeql_pr_preserves_explicit_empty_producer_evidence( + tmp_path: Path, +) -> None: + """An explicit empty evidence response must not acquire fixture defaults.""" + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[_codeql_status("success")], + producer_jobs={}, + producer_artifacts={}, + producer_runs=[], + expect_dispatch_failure=True, + ) + + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + + +def test_codeql_coordinator_app_receipts_require_exact_dispatch_evidence( + tmp_path: Path, +) -> None: + """Coordinator redispatches when App statuses lack producer evidence.""" + statuses = [ + { + "context": f"codeql-dispatch/{language}/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;" + f"s={'c' * 40}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/123" + ), + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + } + for language in ("python", "actions") + ] + result, post_log, _post_body = _run_coordinator( + tmp_path, + statuses=statuses, + producer_jobs=[{"jobs": []}], + producer_artifacts=[{"artifacts": []}], + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.exists() + + +def test_codeql_coordinator_app_receipt_requires_validation_job( + tmp_path: Path, +) -> None: + """Coordinator redispatches when an App receipt omits payload validation.""" + statuses = [ + { + "context": f"codeql-dispatch/{language}/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;" + f"s={'c' * 40}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/123" + ), + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + } + for language in ("python", "actions") + ] + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success", "actions": "success"} + ) + producer_jobs[0]["jobs"] = [ + job for job in producer_jobs[0]["jobs"] + if job["name"] != "validate-dispatch" + ] + + result, post_log, _post_body = _run_coordinator( + tmp_path, + statuses=statuses, + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.exists() + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("event", "pull_request"), + ("path", ".github/workflows/other.yml"), + ("head_sha", "c" * 40), + ("repository", {"full_name": "ContextualWisdomLab/other"}), + ("actor", {"login": "attacker"}), + ("triggering_actor", {"login": "attacker"}), + ], +) +def test_codeql_pr_rejects_self_repository_fallback_without_exact_dispatch_provenance( + tmp_path: Path, field: str, value: object, +) -> None: + """A github-actions status alone cannot impersonate the protected dispatcher.""" + producer_run: dict[str, object] = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "status": "in_progress", + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/.github#42@" + "b" * 40 + + "/" + "a" * 40 + "/42" + ), + } + producer_run[field] = value + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[_codeql_status("success", creator="github-actions[bot]")], + target_repository="ContextualWisdomLab/.github", + producer_run=producer_run, + expect_dispatch_failure=True, + ) + + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + assert "without an authenticated terminal verdict" in dispatch_result.stdout + + +def test_codeql_pr_rejects_multiple_complete_app_receipts( + tmp_path: Path, +) -> None: + """Conflicting evidence-complete App receipts remain ambiguous and fail closed.""" + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[ + _codeql_status("success"), + _codeql_status("failure", producer_run_id=122), + ], + producer_runs=[], + predecessor_jobs={ + "jobs": [ + { + "name": "validate-dispatch", + "status": "completed", + "conclusion": "success", + }, + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "failure", + "run_attempt": 1, + "steps": [ + { + "name": "Enforce CodeQL Medium+ SARIF gate", + "conclusion": "failure", + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, + ], + }, + ] + }, + predecessor_artifacts={ + "artifacts": [ + {"name": "codeql-dispatch-python-122-1", "expired": False} + ] + }, + expect_dispatch_failure=True, + ) + + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 + + +def test_codeql_pr_ignores_trusted_status_without_current_base_receipt( + tmp_path: Path, +) -> None: + """A trusted same-head verdict from an earlier base cannot satisfy this base.""" + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[ + { + "context": "codeql-dispatch/python", + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + }, + _codeql_status("failure"), + ], + producer_state="failure", + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + assert verdict_result.returncode == 1, verdict_result.stderr + assert "did not pass (state=failure)" in verdict_result.stdout + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("context", f"codeql-dispatch/python/{'c' * 40}"), + ("description", f"cwl1;h={'c' * 40};w=codeql-scan-dispatch"), + ("description", f"cwl1;h={'b' * 40};w=other-workflow"), + ( + "target_url", + "https://github.com/ContextualWisdomLab/.github/actions/runs/not-a-run", + ), + ("target_url", "https://example.test/actions/runs/123"), + ], +) +def test_codeql_pr_ignores_incomplete_or_mismatched_receipt( + tmp_path: Path, field: str, value: str, +) -> None: + """Every receipt identity field must match before a verdict is consumed.""" + invalid_status = _codeql_status("success") + invalid_status[field] = value + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[invalid_status, _codeql_status("failure")], + producer_state="failure", + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + assert verdict_result.returncode == 1, verdict_result.stderr + assert "did not pass (state=failure)" in verdict_result.stdout + + +@pytest.mark.parametrize("state,exit_code", [("success", 0), ("failure", 1)]) +def test_codeql_pr_reads_trusted_verdict_on_second_page( + tmp_path: Path, state: str, exit_code: int +) -> None: + """A full first page of forged successes cannot hide a later trusted verdict.""" + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[ + _codeql_status("success", creator="attacker") + for _ in range(100) + ], + second_page=[_codeql_status(state)], + producer_state=state, + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + assert verdict_result.returncode == exit_code, verdict_result.stderr + if state == "success": + assert "Current-head CodeQL dispatch verdict for python: success." in verdict_result.stdout + else: + assert "did not pass (state=failure)" in verdict_result.stdout + + + +def test_codeql_pr_paginates_every_direct_evidence_collection() -> None: + """Shard and coordinator consumers must not stop at 100 jobs or artifacts.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + job_lines = [ + line + for line in workflow.splitlines() + if "producer_jobs=" in line and "/jobs?filter=latest&per_page=100" in line + ] + artifact_lines = [ + line + for line in workflow.splitlines() + if "artifacts=" in line and "/artifacts?name=" in line + ] + + assert len(job_lines) == 4 + assert len(artifact_lines) == 4 + assert all("gh api --paginate --slurp" in line for line in job_lines) + assert all("gh api --paginate --slurp" in line for line in artifact_lines) + assert workflow.count(".[]?.jobs[]?") >= 4 + assert workflow.count(".[]?.artifacts[]?") >= 4 + + +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( + encoding="utf-8" + ) + refs = set( + re.findall( + r"github/codeql-action/(?:init|analyze|upload-sarif)@([0-9a-f]{40})", + workflow, + ) + ) + + assert len(refs) == 1, f"scheduled-security-scan.yml mixes CodeQL action refs: {sorted(refs)}" + + +def test_codeql_shard_releases_runner_and_reads_exact_head_verdict() -> None: + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + shard = workflow.split(" analyze-head:\n", 1)[1].split( + " dispatch-current-head:\n", 1 + )[0] assert "while :; do" not in shard assert "poll_interval_seconds" not in shard @@ -539,14 +1438,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" - 'endpoint="${@: -1}"\n' - 'case "$endpoint" in\n' + 'if [ "${2:-}" = "--paginate" ] && [ "${3:-}" = "--slurp" ]; then\n' + ' printf \'%s\\n\' "$FAKE_STATUSES_JSON"\n' + 'else case "$2" 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", + "esac; fi\n", encoding="utf-8", ) fake_gh.chmod(0o755) @@ -554,25 +1451,27 @@ 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}, - "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_PULL_JSON": json.dumps({ + "head": {"sha": head_sha}, "state": "open", + "base": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", "sha": "a" * 40, + }, + }), + "FAKE_STATUSES_JSON": json.dumps([[]]), "FAKE_POST_LOG": str(post_log), "GH_TOKEN": "fake-token", "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", "PR_NUMBER": "42", + "PR_BASE_REF": "main", + "PR_BASE_SHA": "a" * 40, + "PR_HEAD_REF": "feature", "PR_HEAD_SHA": head_sha, "LANGUAGE": "python", "BUILD_MODE": "none", "RUN_ATTEMPT": "1", "REQUIRED_RUN_ID": "42", + "PRODUCER_SOURCE_SHA": "c" * 40, "GITHUB_OUTPUT": str(output), } dispatch_result = subprocess.run( @@ -606,6 +1505,11 @@ def _write_coordinator_fakes( pull: dict, jobs: dict, statuses: list[dict], + producer_run: dict[str, object], + producer_jobs: list[dict[str, object]], + producer_artifacts: list[dict[str, object]], + predecessor_jobs: list[dict[str, object]], + predecessor_artifacts: list[dict[str, object]], ) -> tuple[Path, Path, Path]: """Install fake gh/curl binaries and return (bin, post_log, post_body).""" fake_bin = tmp_path / "bin" @@ -639,8 +1543,17 @@ def _write_coordinator_fakes( "body=\n" 'case "$path" in\n' " */pulls/*) body=$FAKE_PULL_JSON ;;\n" - " */statuses) body=$FAKE_STATUSES_JSON ;;\n" - " */actions/runs/*/jobs) body=$FAKE_JOBS_JSON ;;\n" + " repos/ContextualWisdomLab/.github/branches/main) body=$FAKE_HANDLER_BRANCH_JSON ;;\n" + " */statuses*) body=$FAKE_STATUSES_JSON ;;\n" + " */actions/workflows/codeql-scan-dispatch.yml/runs*) body=$FAKE_PRODUCER_RUNS_JSON ;;\n" + " */actions/runs/123/jobs*) body=$FAKE_PRODUCER_JOBS_JSON ;;\n" + " */actions/runs/123/artifacts*) body=$FAKE_PRODUCER_ARTIFACTS_JSON ;;\n" + " */actions/runs/123) body=$FAKE_PRODUCER_RUN_JSON ;;\n" + " */actions/runs/122/jobs*) body=$FAKE_PREDECESSOR_JOBS_JSON ;;\n" + " */actions/runs/122/artifacts*) body=$FAKE_PREDECESSOR_ARTIFACTS_JSON ;;\n" + " */actions/runs/122) body=$FAKE_PREDECESSOR_RUN_JSON ;;\n" + " repos/ContextualWisdomLab/.github/compare/*) body=$FAKE_SOURCE_COMPARE_JSON ;;\n" + " */actions/runs/*/jobs*) body=$FAKE_JOBS_JSON ;;\n" " *) exit 1 ;;\n" "esac\n" 'if [ -n "${jq_filter}" ]; then printf \'%s\\n\' "$body" | jq -c "$jq_filter"; else printf \'%s\\n\' "$body"; fi\n', @@ -672,6 +1585,14 @@ def _run_coordinator( pull: dict | None = None, jobs: dict | None = None, statuses: list[dict] | None = None, + producer_jobs: list[dict[str, object]] | None = None, + producer_artifacts: list[dict[str, object]] | None = None, + producer_runs: list[dict[str, object]] | None = None, + predecessor_jobs: list[dict[str, object]] | None = None, + predecessor_artifacts: list[dict[str, object]] | None = None, + handler_source_sha: str | None = None, + protected_tip_sha: str | None = None, + source_compare: dict[str, object] | None = None, env_overrides: dict[str, str] | None = None, ) -> tuple[subprocess.CompletedProcess[str], Path, Path]: """Execute the coordinator dispatch block against fixture-backed APIs.""" @@ -683,7 +1604,10 @@ def _run_coordinator( pull = pull or { "state": "open", "head": {"sha": head_sha, "ref": "feature"}, - "base": {"sha": "a" * 40, "ref": "main"}, + "base": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "sha": "a" * 40, "ref": "main", + }, } jobs = jobs or { "total_count": 2, @@ -703,8 +1627,39 @@ def _run_coordinator( ], } statuses = statuses if statuses is not None else [] + handler_source_sha = handler_source_sha or "c" * 40 + protected_tip_sha = protected_tip_sha or handler_source_sha + producer_run: dict[str, object] = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": handler_source_sha, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + head_sha + "/" + "a" * 40 + "/99/" + "c" * 40 + ), + } + producer_jobs = producer_jobs or [{"jobs": []}] + producer_artifacts = producer_artifacts or [{"artifacts": []}] + predecessor_jobs = predecessor_jobs or [{"jobs": []}] + predecessor_artifacts = predecessor_artifacts or [{"artifacts": []}] + incomplete_predecessor = dict(producer_run) + incomplete_predecessor["id"] = 122 + producer_runs = producer_runs if producer_runs is not None else [producer_run] fake_bin, post_log, post_body = _write_coordinator_fakes( - tmp_path, pull=pull, jobs=jobs, statuses=statuses + tmp_path, + pull=pull, + jobs=jobs, + statuses=statuses, + producer_run=producer_run, + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + predecessor_jobs=predecessor_jobs, + predecessor_artifacts=predecessor_artifacts, ) script = _extract_run_block( WORKFLOW_PATH.read_text(encoding="utf-8"), COORDINATOR_STEP_NAME @@ -714,7 +1669,36 @@ def _run_coordinator( "PATH": f"{fake_bin}:{os.environ['PATH']}", "FAKE_PULL_JSON": json.dumps(pull), "FAKE_JOBS_JSON": json.dumps(jobs), - "FAKE_STATUSES_JSON": json.dumps(statuses), + "FAKE_STATUSES_JSON": json.dumps([statuses]), + "FAKE_PRODUCER_RUNS_JSON": json.dumps([{"workflow_runs": producer_runs}]), + "FAKE_PRODUCER_RUN_JSON": json.dumps(producer_run), + "FAKE_HANDLER_BRANCH_JSON": json.dumps( + { + "name": "main", + "protected": True, + "commit": {"sha": protected_tip_sha}, + } + ), + "FAKE_PREDECESSOR_RUN_JSON": json.dumps(incomplete_predecessor), + "FAKE_PREDECESSOR_JOBS_JSON": json.dumps( + predecessor_jobs if predecessor_jobs is not None else [{"jobs": []}] + ), + "FAKE_PREDECESSOR_ARTIFACTS_JSON": json.dumps( + predecessor_artifacts + if predecessor_artifacts is not None else [{"artifacts": []}] + ), + "FAKE_PRODUCER_JOBS_JSON": json.dumps(producer_jobs), + "FAKE_PRODUCER_ARTIFACTS_JSON": json.dumps(producer_artifacts), + "FAKE_PREDECESSOR_JOBS_JSON": json.dumps(predecessor_jobs), + "FAKE_PREDECESSOR_ARTIFACTS_JSON": json.dumps(predecessor_artifacts), + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + source_compare + or { + "status": "identical", + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "c" * 40}, + } + ), "FAKE_POST_LOG": str(post_log), "FAKE_POST_BODY": str(post_body), "FAKE_CURL_LOG": str(tmp_path / "curl.log"), @@ -727,6 +1711,7 @@ def _run_coordinator( "PR_HEAD_REF": "feature", "PR_HEAD_SHA": head_sha, "REQUIRED_RUN_ID": "99", + "PRODUCER_SOURCE_SHA": "c" * 40, "MATRIX": json.dumps( { "include": [ @@ -748,6 +1733,46 @@ def _run_coordinator( return result, post_log, post_body +def _coordinator_receipt_evidence( + states: dict[str, str], + *, + run_id: int = 123, +) -> tuple[list[dict[str, object]], list[dict[str, object]]]: + """Return completed jobs and retained artifacts for coordinator receipts.""" + jobs = [{ + "name": "validate-dispatch", + "status": "completed", + "conclusion": "success", + }] + jobs.extend( + { + "name": f"CodeQL dispatch scan ({language})", + "status": "completed", + "conclusion": state, + "run_attempt": 1, + "steps": [ + { + "name": "Enforce CodeQL Medium+ SARIF gate", + "conclusion": state, + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, + ], + } + for language, state in states.items() + ) + artifacts = [ + { + "name": f"codeql-dispatch-{language}-{run_id}-1", + "expired": False, + } + for language in states + ] + return [{"jobs": jobs}], [{"artifacts": artifacts}] + + def test_codeql_coordinator_posts_one_dispatch_for_every_pending_language( tmp_path: Path, ) -> None: @@ -761,9 +1786,12 @@ def test_codeql_coordinator_posts_one_dispatch_for_every_pending_language( payload = json.loads(post_body.read_text(encoding="utf-8")) assert payload["event_type"] == "codeql-scan" client = payload["client_payload"] + assert len(client) <= 10, "GitHub repository_dispatch accepts at most ten client_payload fields" assert client["target_repository"] == "ContextualWisdomLab/naruon" assert client["pr_number"] == "42" assert client["required_run_id"] == "99" + assert "rerun_request" not in client + assert "rerun_mode" not in client assert "required_job_id" not in client assert "required_language" not in client languages = [entry["language"] for entry in client["matrix"]] @@ -774,80 +1802,605 @@ def test_codeql_coordinator_posts_one_dispatch_for_every_pending_language( assert jobs_by_language == {"python": 101, "actions": 102} -def test_codeql_coordinator_skips_dispatch_when_every_language_has_a_verdict( +def test_codeql_coordinator_dispatches_against_shared_attempt_base( tmp_path: Path, ) -> None: - """A rerun that already has terminal statuses must not enqueue another scan.""" + """Coordinator uses the same upstream-captured base as every shard.""" + live_base_sha = "d" * 40 result, post_log, post_body = _run_coordinator( tmp_path, - statuses=[ - { - "context": "codeql-dispatch/python", - "state": "success", - "creator": {"login": "opencode-agent[bot]"}, + pull={ + "state": "open", + "head": {"sha": "b" * 40, "ref": "feature"}, + "base": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "sha": live_base_sha, + "ref": "main", }, + }, + env_overrides={"PR_BASE_SHA": live_base_sha}, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.exists() + payload = json.loads(post_body.read_text(encoding="utf-8")) + assert payload["client_payload"]["pr_base_sha"] == live_base_sha + + +def test_codeql_coordinator_recovers_base_that_advanced_after_attempt_capture( + tmp_path: Path, +) -> None: + """Coordinator requests a whole-attempt rerun against the refreshed live base.""" + result, post_log, post_body = _run_coordinator( + tmp_path, + pull={ + "state": "open", + "head": {"sha": "b" * 40, "ref": "feature"}, + "base": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "sha": "d" * 40, + "ref": "main", + }, + }, + jobs={ + "total_count": 2, + "jobs": [ + { + "id": 101, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "success", + }, + { + "id": 102, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", + "conclusion": "failure", + }, + ], + }, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.exists() + client = json.loads(post_body.read_text(encoding="utf-8"))["client_payload"] + assert client["pr_base_sha"] == "d" * 40 + assert client["rerun_request"]["mode"] == "all" + assert client["matrix"] == [ + {"language": "python", "build-mode": "none"}, + {"language": "actions", "build-mode": "none"}, + ] + assert client["rerun_request"]["required_jobs"] == [ + {"language": "python", "job_id": 101}, + {"language": "actions", "job_id": 102}, + ] + + +@pytest.mark.parametrize("predecessor_state", ["success", "failure"]) +def test_codeql_coordinator_rejects_multiple_complete_app_receipts( + tmp_path: Path, predecessor_state: str, +) -> None: + """Coordinator fails closed instead of multiplying ambiguous receipts.""" + statuses = [] + for language in ("python", "actions"): + for run_id, state in ((123, "success"), (122, predecessor_state)): + statuses.append({ + "context": f"codeql-dispatch/{language}/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;" + f"s={'c' * 40}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/" + f"{run_id}" + ), + "state": state, + "creator": {"login": "opencode-agent[bot]"}, + }) + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success", "actions": "success"} + ) + predecessor_jobs, predecessor_artifacts = _coordinator_receipt_evidence( + {"python": predecessor_state, "actions": predecessor_state}, run_id=122 + ) + + result, post_log, _post_body = _run_coordinator( + tmp_path, + statuses=statuses, + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + producer_runs=[], + predecessor_jobs=predecessor_jobs, + predecessor_artifacts=predecessor_artifacts, + ) + + assert result.returncode == 1 + assert "ambiguous" in result.stdout.lower() + assert '"run_id":122' in result.stderr + assert f'"state":"{predecessor_state}"' in result.stderr + assert '"run_id":123' in result.stderr + assert '"state":"success"' in result.stderr + assert not post_log.exists() + assert not (tmp_path / "curl.log").exists() + + +def test_codeql_coordinator_rejects_multiple_complete_direct_runs( + tmp_path: Path, +) -> None: + """Direct producer ambiguity cannot trigger another handler run.""" + title = ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/99/" + "c" * 40 + ) + producer_runs = [ + { + "id": run_id, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": title, + } + for run_id in (123, 122) + ] + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success", "actions": "success"} + ) + predecessor_jobs, predecessor_artifacts = _coordinator_receipt_evidence( + {"python": "success", "actions": "success"}, run_id=122 + ) + + result, post_log, _post_body = _run_coordinator( + tmp_path, + producer_runs=producer_runs, + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + predecessor_jobs=predecessor_jobs, + predecessor_artifacts=predecessor_artifacts, + ) + + assert result.returncode == 1 + assert "ambiguous" in result.stdout.lower() + assert '"run_id":122' in result.stderr + assert '"run_id":123' in result.stderr + assert result.stderr.count('"state":"success"') == 2 + assert not post_log.exists() + assert not (tmp_path / "curl.log").exists() + + +def test_codeql_coordinator_rejects_cross_channel_complete_producers( + tmp_path: Path, +) -> None: + """Coordinator rejects a receipt plus a distinct status-less direct run.""" + receipt = { + "context": f"codeql-dispatch/python/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;s={'c' * 40}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/122" + ), + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + } + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success"} + ) + predecessor_jobs, predecessor_artifacts = _coordinator_receipt_evidence( + {"python": "success"}, run_id=122 + ) + + result, post_log, _post_body = _run_coordinator( + tmp_path, + statuses=[receipt], + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + predecessor_jobs=predecessor_jobs, + predecessor_artifacts=predecessor_artifacts, + ) + + assert result.returncode == 1 + assert '"run_id":122' in result.stderr + assert '"run_id":123' in result.stderr + assert not post_log.exists() + assert not (tmp_path / "curl.log").exists() + + +def test_codeql_coordinator_rejects_receipt_with_mismatched_gate( + tmp_path: Path, +) -> None: + """Coordinator does not skip a scan for a receipt that contradicts its gate.""" + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success"} + ) + scan_job = next( + job for job in producer_jobs[0]["jobs"] + if job["name"] == "CodeQL dispatch scan (python)" + ) + scan_job["steps"][0]["conclusion"] = "failure" + result, post_log, post_body = _run_coordinator( + tmp_path, + statuses=[{ + "context": f"codeql-dispatch/python/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;s={'c' * 40}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/123" + ), + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + }], + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + producer_runs=[], + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.exists() + client = json.loads(post_body.read_text(encoding="utf-8"))["client_payload"] + assert [entry["language"] for entry in client["matrix"]] == ["python", "actions"] + + +def test_codeql_coordinator_keeps_all_failed_jobs_when_one_language_is_pending( + tmp_path: Path, +) -> None: + """Run-wide reruns wake every failed job when any language remains pending.""" + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success"} + ) + result, post_log, post_body = _run_coordinator( + tmp_path, + statuses=[ { - "context": "codeql-dispatch/actions", - "state": "failure", + "context": f"codeql-dispatch/python/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;" + f"s={'c' * 40}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/123" + ), + "state": "success", "creator": {"login": "opencode-agent[bot]"}, - }, + } ], + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.exists() + client = json.loads(post_body.read_text(encoding="utf-8"))["client_payload"] + assert [entry["language"] for entry in client["matrix"]] == ["python", "actions"] + assert { + entry["language"]: entry["job_id"] for entry in client["required_jobs"] + } == {"python": 101, "actions": 102} + + +def test_codeql_coordinator_reads_direct_evidence_on_later_pages( + tmp_path: Path, +) -> None: + """Later-page job and artifact evidence prevents a redundant dispatch.""" + producer_jobs = [ + { + "jobs": [ + { + "name": "validate-dispatch", + "status": "completed", + "conclusion": "success", + } + ] + }, + { + "jobs": [ + { + "name": f"CodeQL dispatch scan ({language})", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + { + "name": "Enforce CodeQL Medium+ SARIF gate", + "conclusion": "success", + }, + { + "name": "Preserve CodeQL SARIF evidence", + "conclusion": "success", + }, + ], + } + for language in ("python", "actions") + ] + }, + ] + producer_artifacts = [ + {"artifacts": []}, + { + "artifacts": [ + { + "name": f"codeql-dispatch-{language}-123-1", + "expired": False, + } + for language in ("python", "actions") + ] + }, + ] + + result, post_log, _post_body = _run_coordinator( + tmp_path, + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, ) assert result.returncode == 0, result.stderr + result.stdout + assert "already have authenticated terminal verdicts" in result.stdout assert not post_log.exists() - assert not post_body.exists() or post_body.read_text(encoding="utf-8") == "" + + +def test_codeql_coordinator_accepts_descendant_handler_source( + tmp_path: Path, +) -> None: + """Coordinator accepts handler main descended from the target PR base.""" + producer_jobs = [ + { + "jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + *[ + { + "name": f"CodeQL dispatch scan ({language})", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + } + for language in ("python", "actions") + ], + ] + } + ] + producer_artifacts = [ + { + "artifacts": [ + {"name": f"codeql-dispatch-{language}-123-1", "expired": False} + for language in ("python", "actions") + ] + } + ] + + result, post_log, _post_body = _run_coordinator( + tmp_path, + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + handler_source_sha="d" * 40, + protected_tip_sha="e" * 40, + source_compare={ + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "base_commit": {"sha": "d" * 40}, + "merge_base_commit": {"sha": "d" * 40}, + }, + ) + + assert result.returncode == 0, result.stderr + result.stdout assert "already have authenticated terminal verdicts" in result.stdout + assert not post_log.exists() -def test_codeql_coordinator_fails_closed_when_a_shard_job_id_is_missing( +def test_codeql_coordinator_selects_complete_duplicate_title_successor( tmp_path: Path, ) -> None: - """A matrix language with no analyze-head job cannot be woken later.""" + """Coordinator validates evidence before enforcing producer uniqueness.""" + complete_jobs = [ + { + "jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + *[ + { + "name": f"CodeQL dispatch scan ({language})", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + } + for language in ("python", "actions") + ], + ] + } + ] + complete_artifacts = [ + { + "artifacts": [ + {"name": f"codeql-dispatch-{language}-123-1", "expired": False} + for language in ("python", "actions") + ] + } + ] + complete = { + "id": 123, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_sha": "c" * 40, + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + "head_branch": "main", + "display_title": ( + "CodeQL Scan Dispatch ContextualWisdomLab/naruon#42@" + + "b" * 40 + "/" + "a" * 40 + "/99/" + "c" * 40 + ), + } + incomplete = dict(complete) + incomplete["id"] = 122 + result, post_log, _post_body = _run_coordinator( + tmp_path, + producer_jobs=complete_jobs, + producer_artifacts=complete_artifacts, + producer_runs=[incomplete, complete], + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert "already have authenticated terminal verdicts" in result.stdout + assert not post_log.exists() + + +def test_codeql_coordinator_excludes_successful_compatibility_jobs_from_settlement( + tmp_path: Path, +) -> None: + """Run-wide settlement carries only exact failed compatibility jobs.""" + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success"} + ) + result, _post_log, post_body = _run_coordinator( tmp_path, jobs={ - "total_count": 1, + "total_count": 2, "jobs": [ { "id": 101, "name": "CodeQL compatibility analysis (python)", "status": "completed", + "conclusion": "success", + }, + { + "id": 102, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", "conclusion": "failure", - } + }, + ], + }, + statuses=[ + { + "context": f"codeql-dispatch/python/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;" + f"s={'c' * 40}" + ), + "target_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/123", + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + } + ], + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, + ) + + assert result.returncode == 0, result.stderr + result.stdout + client = json.loads(post_body.read_text(encoding="utf-8"))["client_payload"] + assert client["required_jobs"] == [{"language": "actions", "job_id": 102}] + + +def test_codeql_coordinator_rejects_unrelated_failed_job_before_dispatch( + tmp_path: Path, +) -> None: + """A run-wide rerun cannot be authorized when another failed job exists.""" + result, post_log, _post_body = _run_coordinator( + tmp_path, + jobs={ + "total_count": 3, + "jobs": [ + { + "id": 101, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "failure", + }, + { + "id": 102, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", + "conclusion": "failure", + }, + { + "id": 103, + "name": "Unrelated failed gate", + "status": "completed", + "conclusion": "failure", + }, ], }, ) assert result.returncode == 1 - assert "missing current-head job id" in result.stdout + assert "failed jobs outside the exact language map" in result.stdout assert not post_log.exists() -def test_codeql_coordinator_dispatches_the_live_base_after_a_same_head_retarget( +def test_codeql_coordinator_skips_dispatch_when_every_language_has_a_verdict( tmp_path: Path, ) -> None: - """A retargeted PR must dispatch against the live base, not the event snapshot.""" - live_base = "c" * 40 + """A rerun that already has terminal statuses must not enqueue another scan.""" + producer_jobs, producer_artifacts = _coordinator_receipt_evidence( + {"python": "success", "actions": "failure"} + ) 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"}, + statuses=[ + { + "context": f"codeql-dispatch/python/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;" + f"s={'c' * 40}" + ), + "target_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/123", + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + }, + { + "context": f"codeql-dispatch/actions/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=99;" + f"s={'c' * 40}" + ), + "target_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/123", + "state": "failure", + "creator": {"login": "opencode-agent[bot]"}, + }, + ], + producer_jobs=producer_jobs, + producer_artifacts=producer_artifacts, ) 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" + assert not post_log.exists() + assert not post_body.exists() or post_body.read_text(encoding="utf-8") == "" + assert "already have authenticated terminal verdicts" in result.stdout + + +def test_codeql_coordinator_fails_closed_when_a_shard_job_id_is_missing( + tmp_path: Path, +) -> None: + """A matrix language with no analyze-head job cannot be woken later.""" + result, post_log, _post_body = _run_coordinator( + tmp_path, + jobs={ + "total_count": 1, + "jobs": [ + { + "id": 101, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "failure", + } + ], + }, + ) + + assert result.returncode == 1 + assert "missing current-head job id" in result.stdout + assert not post_log.exists() def test_codeql_coordinator_does_not_dispatch_a_closed_or_stale_pull_request( diff --git a/tests/test_codeql_scan_dispatch_workflow_contract.py b/tests/test_codeql_scan_dispatch_workflow_contract.py index dd30c8506d..b294ef58e1 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 ( @@ -36,7 +38,8 @@ "Fetch the pinned CodeQL SARIF gate script", "Materialize pull request head for CodeQL scan", "Publish CodeQL dispatch status", - "Wake exact CodeQL required job", + "Exchange OpenCode app token for run settlement", + "Settle exact CodeQL required run", ) @@ -78,7 +81,9 @@ def test_codeql_scan_dispatch_workflow_structure(): assert workflow.count("github/codeql-action/init@") == 1 assert workflow.count("github/codeql-action/analyze@") == 1 assert "scripts/ci/codeql_sarif_gate.py" in workflow - assert 'context="codeql-dispatch/${LANGUAGE}"' in workflow + assert '-f context="codeql-dispatch/${LANGUAGE}/${BASE_SHA}"' in workflow + assert "github.event.client_payload.producer_source_sha" in workflow + assert 'receipt_description="cwl1;h=${HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${PRODUCER_SOURCE_SHA}"' in workflow assert "OPENCODE_REPOSITORY_DISPATCH_ACTOR" in workflow # Deliberately NOT vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS: that allowlist # scopes a gradual ~12-repo OpenCode review rollout, while ruleset @@ -137,7 +142,12 @@ def _run_validate_step(tmp_path: Path, env_overrides: dict[str, str], pull_reque "#!/usr/bin/env bash\n" "set -euo pipefail\n" 'test "$1" = api\n' - 'printf \'%s\\n\' "$FAKE_PULL_JSON"\n', + 'endpoint="${!#}"\n' + 'case "$endpoint" in\n' + ' repos/ContextualWisdomLab/.github/compare/*) printf \'%s\\n\' "$FAKE_SOURCE_COMPARE_JSON" ;;\n' + ' repos/ContextualWisdomLab/*/git/commits/*) printf \'%s\\n\' "$FAKE_PRODUCER_COMMIT_JSON" ;;\n' + ' *) printf \'%s\\n\' "$FAKE_PULL_JSON" ;;\n' + 'esac\n', encoding="utf-8", ) fake_gh.chmod(0o755) @@ -147,6 +157,13 @@ def _run_validate_step(tmp_path: Path, env_overrides: dict[str, str], pull_reque **os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}", "FAKE_PULL_JSON": json.dumps(pull_request), + "FAKE_SOURCE_COMPARE_JSON": "{}", + "FAKE_PRODUCER_COMMIT_JSON": json.dumps( + { + "sha": "c" * 40, + "parents": [{"sha": "a" * 40}, {"sha": "b" * 40}], + } + ), "GITHUB_OUTPUT": str(output), "DISPATCH_ACTOR": "seonghobae", "DISPATCH_SENDER": "seonghobae", @@ -155,11 +172,18 @@ 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_HEAD_REF": "feature", "SUPPLIED_HEAD_SHA": "b" * 40, + "SUPPLIED_LEGACY_HEAD_REF": "feature", + "SUPPLIED_LEGACY_HEAD_SHA": "b" * 40, + "SUPPLIED_PRODUCER_SOURCE_SHA": "c" * 40, "SUPPLIED_MATRIX": json.dumps([{"language": "python", "build-mode": "none"}]), "SUPPLIED_REQUIRED_RUN_ID": "42", "SUPPLIED_REQUIRED_JOBS": json.dumps([{"language": "python", "job_id": 43}]), + "SUPPLIED_RERUN_MODE": "", + "SUPPLIED_RERUN_REQUEST": "null", "SUPPLIED_REQUIRED_JOB_ID": "", "SUPPLIED_REQUIRED_LANGUAGE": "", **env_overrides, @@ -173,6 +197,7 @@ def _matching_pull_request() -> dict: """A live PR payload that matches the default supplied metadata in _run_validate_step.""" return { "state": "open", + "merge_commit_sha": "c" * 40, "base": {"repo": {"full_name": "ContextualWisdomLab/naruon"}, "ref": "main", "sha": "a" * 40}, "head": {"repo": {"full_name": "ContextualWisdomLab/naruon"}, "ref": "feature", "sha": "b" * 40}, } @@ -189,11 +214,316 @@ def test_codeql_scan_dispatch_validate_step_accepts_matching_live_metadata(tmp_p assert "head_sha=" + "b" * 40 in output_text assert '[{"language":"python","build-mode":"none"}]' in output_text assert "required_run_id=42" in output_text + assert "producer_source_sha=" + "c" * 40 in output_text assert '"job_id":43' in output_text.replace(" ", "") assert "required_job_id=" not in output_text 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_HEAD_REF": "feature", + "SUPPLIED_HEAD_SHA": "b" * 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 + + +@pytest.mark.parametrize( + ("legacy_ref", "legacy_sha"), + [ + ("feature-wrong", "b" * 40), + ("feature", "c" * 40), + ("feature", ""), + ("", "b" * 40), + ], +) +def test_codeql_scan_dispatch_validate_step_rejects_conflicting_dual_head_identity( + tmp_path, legacy_ref, legacy_sha +): + """Nested identity cannot shadow an unequal or partial legacy representation.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_HEAD_ENVELOPE": json.dumps( + {"schema": "1", "ref": "feature", "sha": "b" * 40} + ), + "SUPPLIED_HEAD_SCHEMA": "1", + "SUPPLIED_HEAD_REF": "feature", + "SUPPLIED_HEAD_SHA": "b" * 40, + "SUPPLIED_LEGACY_HEAD_REF": legacy_ref, + "SUPPLIED_LEGACY_HEAD_SHA": legacy_sha, + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "conflicting nested and legacy pr_head identity" in result.stdout + + +def test_codeql_scan_dispatch_validate_step_rejects_numeric_head_schema(tmp_path): + """The JSON envelope schema stays a version string, not a numeric alias.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_HEAD_ENVELOPE": json.dumps( + {"schema": 1, "ref": "feature", "sha": "b" * 40} + ), + "SUPPLIED_HEAD_SCHEMA": "1", + "SUPPLIED_HEAD_REF": "feature", + "SUPPLIED_HEAD_SHA": "b" * 40, + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "invalid 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 "invalid 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_accepts_nested_rerun_request(tmp_path): + """The bounded ten-key producer envelope normalizes mode and job identities.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_REQUIRED_JOBS": "null", + "SUPPLIED_RERUN_REQUEST": json.dumps( + { + "mode": "failed", + "required_jobs": [{"language": "python", "job_id": 43}], + } + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 0, result.stderr + output_text = result.output_path.read_text(encoding="utf-8") + assert "rerun_mode=failed" in output_text + assert '"job_id":43' in output_text.replace(" ", "") + + +def test_codeql_scan_dispatch_validate_step_binds_producer_revision(tmp_path): + """Only the exact live base/head merge revision can invoke the handler.""" + missing = _run_validate_step( + tmp_path / "missing", + {"SUPPLIED_PRODUCER_SOURCE_SHA": ""}, + _matching_pull_request(), + ) + wrong_revision = _run_validate_step( + tmp_path / "wrong-revision", + { + "SUPPLIED_PRODUCER_SOURCE_SHA": "d" * 40, + "FAKE_PRODUCER_COMMIT_JSON": json.dumps( + { + "sha": "d" * 40, + "parents": [{"sha": "a" * 40}, {"sha": "b" * 40}], + } + ), + }, + _matching_pull_request(), + ) + wrong_parents = _run_validate_step( + tmp_path / "wrong-parents", + { + "FAKE_PRODUCER_COMMIT_JSON": json.dumps( + { + "sha": "c" * 40, + "parents": [{"sha": "f" * 40}, {"sha": "b" * 40}], + } + ), + }, + _matching_pull_request(), + ) + + assert missing.returncode == 1 + assert wrong_revision.returncode == 1 + assert wrong_parents.returncode == 1 + assert "producer source" in missing.stdout.lower() + assert "producer revision" in wrong_revision.stdout.lower() + assert "producer revision" in wrong_parents.stdout.lower() + + +def test_codeql_scan_dispatch_accepts_exact_pull_request_merge_revision(tmp_path): + """Bind the producer revision to the live PR base/head merge, not handler ancestry.""" + merge_sha = "e" * 40 + pull_request = _matching_pull_request() + pull_request["merge_commit_sha"] = merge_sha + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_PRODUCER_SOURCE_SHA": merge_sha, + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + { + "status": "diverged", + "behind_by": 1, + "base_commit": {"sha": "f" * 40}, + "merge_base_commit": {"sha": "f" * 40}, + } + ), + "FAKE_PRODUCER_COMMIT_JSON": json.dumps( + { + "sha": merge_sha, + "parents": [ + {"sha": "a" * 40}, + {"sha": "b" * 40}, + ], + } + ), + }, + pull_request, + ) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_codeql_scan_dispatch_validate_step_accepts_legacy_rerun_mode(tmp_path): + """An already queued top-level mode retains whole-attempt semantics.""" + result = _run_validate_step( + tmp_path, + {"SUPPLIED_RERUN_MODE": "all"}, + _matching_pull_request(), + ) + + assert result.returncode == 0, result.stderr + assert "rerun_mode=all" in result.output_path.read_text(encoding="utf-8") + + +def test_codeql_scan_dispatch_validate_step_rejects_conflicting_rerun_envelopes( + tmp_path, +): + """A caller cannot supply both legacy and nested rerun authority.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_RERUN_REQUEST": json.dumps( + { + "mode": "failed", + "required_jobs": [{"language": "python", "job_id": 43}], + } + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "conflicting legacy and nested rerun envelopes" in result.stdout + + +def test_codeql_scan_dispatch_validate_step_rejects_unknown_rerun_mode(tmp_path): + """Only the two run-wide GitHub rerun operations are accepted.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_REQUIRED_JOBS": "null", + "SUPPLIED_RERUN_REQUEST": json.dumps( + { + "mode": "one-job", + "required_jobs": [{"language": "python", "job_id": 43}], + } + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "rerun mode" in result.stdout + + +def test_codeql_scan_dispatch_validate_step_rejects_duplicate_job_id(tmp_path): + """Two language labels cannot authorize mutation of the same required job.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_MATRIX": json.dumps( + [ + {"language": "python", "build-mode": "none"}, + {"language": "actions", "build-mode": "none"}, + ] + ), + "SUPPLIED_REQUIRED_JOBS": json.dumps( + [ + {"language": "python", "job_id": 43}, + {"language": "actions", "job_id": 43}, + ] + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "wake identity is missing" 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()) @@ -357,6 +687,28 @@ def test_codeql_scan_dispatch_validate_step_accepts_multi_language_payload(tmp_p assert '"job_id":43' in output_text.replace(" ", "") +def test_codeql_scan_dispatch_validate_step_rejects_unproven_matrix_subset(tmp_path): + """A partial scan cannot authorize waking an unscanned required language.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_MATRIX": json.dumps( + [{"language": "actions", "build-mode": "none"}] + ), + "SUPPLIED_REQUIRED_JOBS": json.dumps( + [ + {"language": "python", "job_id": 43}, + {"language": "actions", "job_id": 44}, + ] + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "does not match the dispatched languages one-to-one" in result.stdout + + def test_codeql_scan_dispatch_validate_step_accepts_legacy_single_language_payload(tmp_path): """A queued pre-cutover payload still validates after required_jobs became mandatory. @@ -534,65 +886,105 @@ def test_dispatch_publish_keeps_successful_scan_when_status_write_is_denied() -> """ 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 + "\n\n settle-required-run:\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 "exact completed scan and preserved SARIF artifact remain" 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: +def test_dispatch_publish_rejects_superseded_metadata_and_legacy_context() -> None: + """A stale handler cannot poison HEAD or publish an unbound legacy status. + + Run 34235814716 proved that a scan can become superseded after initial + validation but before publication. #1902's evidence-complete producer is + integrated into the same successor, so publication requires successful + live-metadata revalidation and emits only the base-bound receipt. + """ workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - wake = workflow.split(" - name: Wake exact CodeQL required job\n", 1)[1].split( - "\n\n - name:", 1 + revalidate = workflow.split( + " - name: Re-validate live pull request metadata before privileged scan\n", + 1, + )[1].split(" - name: Fetch the pinned CodeQL SARIF gate script\n", 1)[0] + publish = workflow.split(" - name: Publish CodeQL dispatch status\n", 1)[1].split( + "\n\n settle-required-run:\n", 1 )[0] - assert "steps.publish_status.outcome == 'success'" in wake - assert 'gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"' in wake - assert 'gh api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}"' in wake - assert 'gh api "repos/${TARGET_REPOSITORY}/actions/jobs/${REQUIRED_JOB_ID}"' in wake - assert 'select(.event == "pull_request")' in wake - assert 'select(.path == ".github/workflows/codeql-pr.yml")' in wake - assert "select(.head_sha == $head)" in wake - assert "select(.run_id == $run_id)" in wake - assert "select(.name == $name)" in wake - assert 'select(.status == "completed" and .conclusion == "failure")' in wake - assert 'actions/jobs/${REQUIRED_JOB_ID}/rerun' in wake - assert "rerun-failed-jobs" not in wake - assert "while " not in wake - assert "sleep " not in wake - - -def test_dispatch_wake_has_only_trusted_actions_write_boundary() -> None: + assert " id: live_metadata\n" in revalidate + assert "if: always() && steps.live_metadata.outcome == 'success'" in publish + assert '-f context="codeql-dispatch/${LANGUAGE}/${BASE_SHA}"' in publish + assert '-f context="codeql-dispatch/${LANGUAGE}"' not in publish + assert "SARIF_UPLOAD_OUTCOME: ${{ steps.sarif_upload.outcome }}" in publish + assert 'if [ "${SARIF_UPLOAD_OUTCOME:-}" != "success" ]; then' in publish + assert 'actual_creator="$(jq -r' in publish + assert "unexpected creator" in publish + + +def test_dispatch_settles_all_languages_with_one_run_wide_mutation() -> None: + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + settlement = workflow.split(" settle-required-run:\n", 1)[1] + + assert "needs: [validate-dispatch, scan]" in settlement + assert "always()" in settlement.split(" runs-on:", 1)[0] + assert "actions: write" in settlement.split(" steps:\n", 1)[0] + assert 'github_api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"' in settlement + assert 'github_api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}"' in settlement + assert 'github_api --paginate --slurp "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs?per_page=100"' in settlement + assert "rerun-failed-jobs" in settlement + assert '"rerun"' in settlement + assert "actions/jobs/${REQUIRED_JOB_ID}/rerun" not in workflow + assert "sleep " not in settlement + + +def test_dispatch_settlement_has_only_trusted_actions_write_boundary() -> None: workflow = WORKFLOW_PATH.read_text(encoding="utf-8") scan = workflow.split(" scan:\n", 1)[1] scan_permissions = scan.split(" strategy:\n", 1)[0] + settlement = workflow.split(" settle-required-run:\n", 1)[1] + settlement_permissions = settlement.split(" steps:\n", 1)[0] - assert "actions: write" in scan_permissions + assert "actions: write" not in scan_permissions + assert "actions: read" in scan_permissions + assert "actions: write" in settlement_permissions assert "pull_request:" not in workflow assert "pull_request_target:" not in workflow - assert "needs.validate-dispatch.outputs.required_run_id != ''" in scan - assert "needs.validate-dispatch.outputs.required_jobs != ''" in scan + assert "needs.validate-dispatch.outputs.required_run_id" in settlement + assert "needs.validate-dispatch.outputs.required_jobs" in settlement assert "github.event.client_payload.required_job_id" not in scan -def _run_wake_step( +def _run_settlement_step( tmp_path: Path, *, pull: dict | None = None, run: dict | None = None, - job: dict | None = None, + required_jobs: list[dict] | None = None, + handler_jobs: list[dict] | None = None, + handler_artifacts: list[dict] | None = None, + extra_env: dict[str, str] | None = None, ) -> tuple[subprocess.CompletedProcess[str], Path]: - """Execute the exact wake block against fixture-backed GitHub API responses.""" + """Execute the run-wide settlement block against fixture-backed API responses.""" bash = shutil.which("bash") jq = shutil.which("jq") assert bash is not None and jq is not None, "bash and jq are required to run this test" head_sha = "b" * 40 - pull = pull or {"state": "open", "head": {"sha": head_sha}} + pull = pull or { + "state": "open", + "base": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", + "sha": "a" * 40, + }, + "head": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "feature", + "sha": head_sha, + }, + } run = run or { "id": 42, "event": "pull_request", @@ -601,16 +993,60 @@ def _run_wake_step( "status": "completed", "conclusion": "failure", } - job = job or { - "id": 43, - "run_id": 42, - "head_sha": head_sha, - "name": "CodeQL compatibility analysis (python)", - "status": "completed", - "conclusion": "failure", - } + required_jobs = required_jobs or [ + { + "id": 43, + "run_id": 42, + "head_sha": head_sha, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "failure", + }, + { + "id": 44, + "run_id": 42, + "head_sha": head_sha, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", + "conclusion": "failure", + }, + ] + handler_jobs = handler_jobs or [ + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + { + "name": "CodeQL dispatch scan (actions)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ] + handler_artifacts = handler_artifacts or [ + { + "name": "codeql-dispatch-python-100-1", + "expired": False, + "size_in_bytes": 10, + }, + { + "name": "codeql-dispatch-actions-100-1", + "expired": False, + "size_in_bytes": 10, + }, + ] script = _extract_run_block( - WORKFLOW_PATH.read_text(encoding="utf-8"), "Wake exact CodeQL required job" + WORKFLOW_PATH.read_text(encoding="utf-8"), "Settle exact CodeQL required run" ) fake_bin = tmp_path / "bin" fake_bin.mkdir(parents=True) @@ -620,15 +1056,30 @@ def _run_wake_step( "#!/usr/bin/env bash\n" "set -euo pipefail\n" 'test "$1" = api\n' - 'if [ "${2:-}" = "-X" ]; then\n' - ' test "$3" = POST\n' - ' printf \'%s\\n\' "$4" >>"$FAKE_POST_LOG"\n' + 'endpoint="${!#}"\n' + 'if printf \'%s\\n\' "$@" | grep -qx POST; then\n' + ' printf \'%s\\n\' "$endpoint" >>"$FAKE_POST_LOG"\n' + ' if [ -n "${FAKE_WAKE_POST_FAIL_TOKEN:-}" ] && ' + '[ "${GH_TOKEN:-}" = "$FAKE_WAKE_POST_FAIL_TOKEN" ]; then\n' + " exit 1\n" + " fi\n" + ' if [ -n "${FAKE_DENIED_TOKEN:-}" ] && ' + '[ "${GH_TOKEN:-}" = "$FAKE_DENIED_TOKEN" ]; then\n' + " exit 1\n" + " fi\n" + ' if [ "${FAKE_WAKE_POST_FAIL_ALL:-}" = "1" ]; then\n' + " exit 1\n" + " fi\n" + ' test "${FAKE_POST_EXIT:-0}" = 0 || exit "$FAKE_POST_EXIT"\n' " exit 0\n" "fi\n" - 'case "$2" in\n' + 'test "${GH_TOKEN:-}" != "${FAKE_DENIED_TOKEN:-}" || exit 1\n' + 'case "$endpoint" in\n' ' */pulls/*) printf \'%s\\n\' "$FAKE_PULL_JSON" ;;\n' - ' */actions/runs/*) printf \'%s\\n\' "$FAKE_RUN_JSON" ;;\n' - ' */actions/jobs/*) printf \'%s\\n\' "$FAKE_JOB_JSON" ;;\n' + ' repos/ContextualWisdomLab/naruon/actions/runs/42/jobs*) printf \'%s\\n\' "$FAKE_REQUIRED_JOB_PAGES" ;;\n' + ' repos/ContextualWisdomLab/naruon/actions/runs/42) printf \'%s\\n\' "$FAKE_RUN_JSON" ;;\n' + ' repos/ContextualWisdomLab/.github/actions/runs/100/jobs*) printf \'%s\\n\' "$FAKE_HANDLER_JOB_PAGES" ;;\n' + ' repos/ContextualWisdomLab/.github/actions/runs/100/artifacts*) printf \'%s\\n\' "$FAKE_HANDLER_ARTIFACT_PAGES" ;;\n' " *) exit 1 ;;\n" "esac\n", encoding="utf-8", @@ -639,12 +1090,28 @@ def _run_wake_step( "PATH": f"{fake_bin}:{os.environ['PATH']}", "FAKE_PULL_JSON": json.dumps(pull), "FAKE_RUN_JSON": json.dumps(run), - "FAKE_JOB_JSON": json.dumps(job), + "FAKE_REQUIRED_JOB_PAGES": json.dumps([{"jobs": required_jobs}]), + "FAKE_HANDLER_JOB_PAGES": json.dumps([{"jobs": handler_jobs}]), + "FAKE_HANDLER_ARTIFACT_PAGES": json.dumps( + [{"artifacts": handler_artifacts}] + ), "FAKE_POST_LOG": str(post_log), + "FAKE_POST_EXIT": "0", + "FAKE_DENIED_TOKEN": "", "GH_TOKEN": "fake-token", - "WAKE_TOKEN_SOURCE": "PR_REVIEW_MERGE_TOKEN", + "TARGET_APP_WAKE_TOKEN": "", + "PR_REVIEW_MERGE_WAKE_TOKEN": "", + "OPENCODE_APPROVE_WAKE_TOKEN": "", + "GITHUB_WAKE_TOKEN": "fake-token", + "HANDLER_READ_TOKEN": "handler-token", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "GITHUB_RUN_ID": "100", + "GITHUB_RUN_ATTEMPT": "1", "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", "PR_NUMBER": "42", + "BASE_REF": "main", + "BASE_SHA": "a" * 40, + "HEAD_REF": "feature", "HEAD_SHA": head_sha, "REQUIRED_RUN_ID": "42", "REQUIRED_JOBS": json.dumps( @@ -653,28 +1120,126 @@ def _run_wake_step( {"language": "actions", "job_id": 44}, ] ), - "REQUIRED_LANGUAGE": "python", + "RERUN_MODE": "failed", } + if extra_env: + env.update(extra_env) result = subprocess.run( [bash], input=script, text=True, capture_output=True, check=False, env=env ) return result, post_log -def test_dispatch_wake_reruns_only_fixture_bound_exact_job(tmp_path: Path) -> None: - result, post_log = _run_wake_step(tmp_path) +def test_dispatch_settlement_reruns_two_languages_once(tmp_path: Path) -> None: + result, post_log = _run_settlement_step(tmp_path) assert result.returncode == 0, result.stderr assert post_log.read_text(encoding="utf-8").splitlines() == [ - "repos/ContextualWisdomLab/naruon/actions/jobs/43/rerun" + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs" ] -def test_dispatch_wake_rejects_stale_head_and_closed_pr(tmp_path: Path) -> None: - stale_result, stale_log = _run_wake_step( +def test_dispatch_settlement_fails_closed_when_no_credential( + tmp_path: Path, +) -> None: + result, post_log = _run_settlement_step( + tmp_path, + extra_env={ + "GH_TOKEN": "", + "TARGET_APP_WAKE_TOKEN": "", + "PR_REVIEW_MERGE_WAKE_TOKEN": "", + "OPENCODE_APPROVE_WAKE_TOKEN": "", + "GITHUB_WAKE_TOKEN": "", + }, + ) + + assert result.returncode == 1 + assert "could not read the current pull request" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_falls_back_when_target_app_token_cannot_rerun( + tmp_path: Path, +) -> None: + """A nonempty App token without Actions write must not shadow fallbacks.""" + result, post_log = _run_settlement_step( + tmp_path, + extra_env={ + "TARGET_APP_WAKE_TOKEN": "forbidden-app-token", + "PR_REVIEW_MERGE_WAKE_TOKEN": "actions-write-token", + "OPENCODE_APPROVE_WAKE_TOKEN": "", + "GITHUB_WAKE_TOKEN": "", + "GH_TOKEN": "", + "FAKE_WAKE_POST_FAIL_TOKEN": "forbidden-app-token", + }, + ) + + assert result.returncode == 0, result.stderr + assert ( + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs" + in post_log.read_text(encoding="utf-8") + ) + assert "pr-review-merge-token" in result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + ] + + +def test_dispatch_settlement_fails_closed_after_every_wake_is_denied( + tmp_path: Path, +) -> None: + """A clean scan is not authoritative until one exact-job wake is accepted.""" + result, post_log = _run_settlement_step( + tmp_path, + extra_env={ + "TARGET_APP_WAKE_TOKEN": "app-token", + "PR_REVIEW_MERGE_WAKE_TOKEN": "merge-token", + "OPENCODE_APPROVE_WAKE_TOKEN": "approve-token", + "GITHUB_WAKE_TOKEN": "github-token", + "GH_TOKEN": "", + "FAKE_WAKE_POST_FAIL_ALL": "1", + }, + ) + + assert result.returncode == 1 + assert "could not enqueue verified run-wide recovery" in result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + ] + + +def test_dispatch_settlement_retries_reads_with_next_configured_credential( + tmp_path: Path, +) -> None: + result, post_log = _run_settlement_step( + tmp_path, + extra_env={ + "GH_TOKEN": "target-token", + "TARGET_APP_WAKE_TOKEN": "target-token", + "PR_REVIEW_MERGE_WAKE_TOKEN": "fallback-token", + "OPENCODE_APPROVE_WAKE_TOKEN": "", + "GITHUB_WAKE_TOKEN": "", + "FAKE_DENIED_TOKEN": "target-token", + }, + ) + + assert result.returncode == 0, result.stderr + assert "pr-review-merge-token" in result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs", + ] + + +def test_dispatch_settlement_rejects_stale_head_and_closed_pr(tmp_path: Path) -> None: + stale_result, stale_log = _run_settlement_step( tmp_path / "stale", pull={"state": "open", "head": {"sha": "c" * 40}} ) - closed_result, closed_log = _run_wake_step( + closed_result, closed_log = _run_settlement_step( tmp_path / "closed", pull={"state": "closed", "head": {"sha": "b" * 40}} ) @@ -684,10 +1249,52 @@ def test_dispatch_wake_rejects_stale_head_and_closed_pr(tmp_path: Path) -> None: assert not closed_log.exists() -def test_dispatch_wake_rejects_ambiguous_or_nonfailed_job_identity(tmp_path: Path) -> None: - wrong_job_result, wrong_job_log = _run_wake_step( - tmp_path / "wrong-job", - job={ +def test_dispatch_settlement_rejects_changed_repository_or_head_ref(tmp_path: Path) -> None: + """Settlement revalidates the complete live PR repository/ref identity.""" + wrong_repository, wrong_repository_log = _run_settlement_step( + tmp_path / "wrong-repository", + pull={ + "state": "open", + "base": {"repo": {"full_name": "ContextualWisdomLab/other"}, "ref": "main", "sha": "a" * 40}, + "head": {"repo": {"full_name": "ContextualWisdomLab/naruon"}, "ref": "feature", "sha": "b" * 40}, + }, + ) + changed_ref, changed_ref_log = _run_settlement_step( + tmp_path / "changed-ref", + pull={ + "state": "open", + "base": {"repo": {"full_name": "ContextualWisdomLab/naruon"}, "ref": "main", "sha": "a" * 40}, + "head": {"repo": {"full_name": "ContextualWisdomLab/naruon"}, "ref": "other", "sha": "b" * 40}, + }, + ) + + assert wrong_repository.returncode == 1 + assert changed_ref.returncode == 1 + assert not wrong_repository_log.exists() + assert not changed_ref_log.exists() + + +def test_dispatch_settlement_rejects_successful_required_run(tmp_path: Path) -> None: + """A completed success cannot be mutated as though it were a failed attempt.""" + result, post_log = _run_settlement_step( + tmp_path, + run={ + "id": 42, + "event": "pull_request", + "path": ".github/workflows/codeql-pr.yml", + "head_sha": "b" * 40, + "status": "completed", + "conclusion": "success", + }, + ) + + assert result.returncode == 1 + assert not post_log.exists() + + +def test_dispatch_settlement_rejects_wrong_or_nonfailed_job_identity(tmp_path: Path) -> None: + wrong_jobs = [ + { "id": 43, "run_id": 999, "head_sha": "b" * 40, @@ -695,42 +1302,171 @@ def test_dispatch_wake_rejects_ambiguous_or_nonfailed_job_identity(tmp_path: Pat "status": "completed", "conclusion": "failure", }, - ) - successful_job_result, successful_job_log = _run_wake_step( - tmp_path / "successful-job", - job={ - "id": 43, + { + "id": 44, "run_id": 42, "head_sha": "b" * 40, - "name": "CodeQL compatibility analysis (python)", + "name": "CodeQL compatibility analysis (actions)", "status": "completed", - "conclusion": "success", + "conclusion": "failure", }, + ] + wrong_job_result, wrong_job_log = _run_settlement_step( + tmp_path / "wrong-job", + required_jobs=wrong_jobs, + ) + successful_jobs = [dict(job) for job in wrong_jobs] + successful_jobs[0].update(run_id=42, conclusion="success") + successful_job_result, successful_job_log = _run_settlement_step( + tmp_path / "successful-job", + required_jobs=successful_jobs, ) assert wrong_job_result.returncode == 1 assert successful_job_result.returncode == 1 - assert "missing or ambiguous exact run/job identity" in wrong_job_result.stdout + assert "missing or ambiguous exact job identity" in wrong_job_result.stdout assert not wrong_job_log.exists() assert not successful_job_log.exists() -def test_dispatch_wake_allows_parallel_language_rerun_on_same_exact_run(tmp_path: Path) -> None: - """Another language may already have moved the shared run back to in_progress.""" - result, post_log = _run_wake_step( - tmp_path, - run={ - "id": 42, - "event": "pull_request", - "path": ".github/workflows/codeql-pr.yml", +def test_dispatch_settlement_all_mode_reruns_success_and_failure_jobs(tmp_path: Path) -> None: + all_jobs = [ + { + "id": 43, + "run_id": 42, "head_sha": "b" * 40, - "status": "in_progress", - "conclusion": None, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "success", + }, + { + "id": 44, + "run_id": 42, + "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", + "conclusion": "failure", }, + ] + result, post_log = _run_settlement_step( + tmp_path, + required_jobs=all_jobs, + extra_env={"RERUN_MODE": "all"}, ) assert result.returncode == 0, result.stderr - assert post_log.exists() + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun" + ] + + +def test_dispatch_settlement_rejects_missing_handler_artifact(tmp_path: Path) -> None: + result, post_log = _run_settlement_step( + tmp_path, + handler_artifacts=[ + { + "name": "codeql-dispatch-python-100-1", + "expired": False, + "size_in_bytes": 10, + } + ], + ) + + assert result.returncode == 1 + assert "incomplete handler gate or SARIF evidence for actions" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_rejects_missing_handler_gate_steps(tmp_path: Path) -> None: + """A terminal scan name alone is not authenticated gate evidence.""" + result, post_log = _run_settlement_step( + tmp_path, + handler_jobs=[ + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [], + }, + { + "name": "CodeQL dispatch scan (actions)", + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ], + ) + + assert result.returncode == 1 + assert "incomplete handler gate or SARIF evidence for python" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_rejects_unproven_matrix_subset(tmp_path: Path) -> None: + """Every required shard needs current handler gate and artifact evidence.""" + result, post_log = _run_settlement_step( + tmp_path, + handler_jobs=[ + { + "name": "CodeQL dispatch scan (actions)", + "status": "completed", + "conclusion": "success", + } + ], + handler_artifacts=[ + { + "name": "codeql-dispatch-actions-100-1", + "expired": False, + "size_in_bytes": 10, + } + ], + ) + + assert result.returncode == 1 + assert "incomplete handler gate or SARIF evidence for python" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_rejects_unrelated_failed_job(tmp_path: Path) -> None: + unrelated = { + "id": 45, + "run_id": 42, + "head_sha": "b" * 40, + "name": "unrelated required job", + "status": "completed", + "conclusion": "failure", + } + result, post_log = _run_settlement_step( + tmp_path, + required_jobs=[ + { + "id": 43, + "run_id": 42, + "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "failure", + }, + { + "id": 44, + "run_id": 42, + "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", + "conclusion": "failure", + }, + unrelated, + ], + ) + + assert result.returncode == 1 + assert "unrelated failed jobs" in result.stdout + assert not post_log.exists() def test_codeql_scan_dispatch_serialises_the_matrix_payload() -> None: @@ -759,6 +1495,10 @@ def test_codeql_scan_dispatch_serialises_the_matrix_payload() -> None: "SUPPLIED_REQUIRED_JOBS: ${{ toJSON(github.event.client_payload.required_jobs) }}" in workflow ), "SUPPLIED_REQUIRED_JOBS must be serialised with toJSON(); a bare array breaks template validation" + assert ( + "SUPPLIED_RERUN_REQUEST: ${{ toJSON(github.event.client_payload.rerun_request) }}" + in workflow + ), "The bounded nested rerun envelope must be serialized before shell validation" assert ( "SUPPLIED_REQUIRED_JOB_ID: ${{ github.event.client_payload.required_job_id || '' }}" in workflow @@ -767,3 +1507,6 @@ def test_codeql_scan_dispatch_serialises_the_matrix_payload() -> None: "SUPPLIED_REQUIRED_LANGUAGE: ${{ github.event.client_payload.required_language || '' }}" in workflow ), "Queued pre-cutover payloads still supply required_language as a scalar" + assert "SUPPLIED_LEGACY_HEAD_REF: ${{ github.event.client_payload.pr_head_ref || '' }}" in workflow + assert "SUPPLIED_LEGACY_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }}" in workflow + assert "conflicting nested and legacy pr_head identity" in workflow diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index ba47b89c8d..afd1303a45 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -34,6 +34,8 @@ def workflow_starting_mutation_credential(monkeypatch): workflow-starting credential exactly like the scheduler workflow does. """ monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") @pytest.fixture(autouse=True) @@ -1785,7 +1787,11 @@ def map(self, func, items): ), ) cancelled = [] - monkeypatch.setattr(sched, "run_github_actions", cancelled.append) + monkeypatch.setattr( + sched, + "run_github_actions", + lambda args, stdin=None: cancelled.append(args), + ) monkeypatch.setattr(sched, "require_github_actions_control_actor", lambda x: None) run_ids = sched.cancel_stale_opencode_runs("owner/repo", "workflow", make_pr(), dry_run=False) @@ -1796,7 +1802,7 @@ def map(self, func, items): def test_force_cancel_failure_logs_reason_and_does_not_raise(monkeypatch, capsys): - def fail_cancel(args): + def fail_cancel(args, stdin=None): raise RuntimeError( "Command failed (1): gh api -X POST " "repos/owner/repo/actions/runs/29263154177/force-cancel; " @@ -1821,7 +1827,7 @@ def fail_cancel(args): def test_force_cancel_multiple_runs_reports_only_failures(monkeypatch): - def maybe_fail(args): + def maybe_fail(args, stdin=None): if "runs/2/force-cancel" in " ".join(args): raise RuntimeError("GitHub returned HTTP 500") return "" @@ -10129,13 +10135,17 @@ def test_pr1669_snapshot_race_preserves_new_current_head(monkeypatch): calls = [] def fake_api(path): - calls.append(path) - if path.endswith("/actions/runs/77"): - return candidate + calls.append(("read", path)) return {"state": "open", "draft": False, "head": {"sha": new_head}} + def fake_actions(_repo, args, *, stdin=None): + calls.append(("actions", args[-1])) + assert stdin is None + return json.dumps(candidate) + cancelled = [] monkeypatch.setattr(sched, "gh_api_json", fake_api) + monkeypatch.setattr(sched, "run_github_actions_for_repository", fake_actions) monkeypatch.setattr( sched, "force_cancel_workflow_runs", @@ -10145,7 +10155,7 @@ def fake_api(path): "owner/repo", make_pr(number=7, headRefOid=old_head), dry_run=False ) == [] assert cancelled == [] - assert calls[-1] == "repos/owner/repo/pulls/7" + assert calls[-1] == ("read", "repos/owner/repo/pulls/7") @pytest.mark.parametrize( @@ -10167,11 +10177,45 @@ def test_pr1669_fresh_open_pr_fails_closed_without_open_exact_head(monkeypatch, @pytest.mark.parametrize("payload", [None, {"status": "completed"}]) def test_pr1669_fresh_active_run_requires_active_mapping(monkeypatch, payload): """Only a freshly active run mapping can authorize destructive cancellation.""" - monkeypatch.setattr(sched, "gh_api_json", lambda _path: payload) + monkeypatch.setattr( + sched, + "run_github_actions_for_repository", + lambda *_args, **_kwargs: json.dumps(payload), + ) with pytest.raises(ValueError, match="is not active"): sched._fresh_active_run_for_cancellation("owner/repo", "94") +def test_fresh_central_run_revalidation_uses_host_scoped_actions_credential(monkeypatch): + """A denied general read token cannot hide a stale central Actions run.""" + calls = [] + + def deny_general_read(_path): + raise AssertionError("general read token must not inspect Actions runs") + + def read_actions(repo, args, *, stdin=None): + calls.append((repo, tuple(args), stdin)) + return json.dumps({"status": "in_progress"}) + + monkeypatch.setattr(sched, "gh_api_json", deny_general_read) + monkeypatch.setattr(sched, "run_github_actions_for_repository", read_actions) + + assert sched._fresh_active_run_for_cancellation( + "ContextualWisdomLab/.github", "94" + ) == {"status": "in_progress"} + assert calls == [ + ( + "ContextualWisdomLab/.github", + ( + "gh", + "api", + "repos/ContextualWisdomLab/.github/actions/runs/94", + ), + None, + ) + ] + + @pytest.mark.parametrize( "run", [ @@ -10194,9 +10238,12 @@ def test_pr1669_direct_revalidation_rejects_changed_run_identity(monkeypatch, ru monkeypatch.setattr( sched, "gh_api_json", - lambda path: run - if "/actions/runs/" in path - else {"state": "open", "draft": False, "head": {"sha": "b" * 40}}, + lambda _path: {"state": "open", "draft": False, "head": {"sha": "b" * 40}}, + ) + monkeypatch.setattr( + sched, + "run_github_actions_for_repository", + lambda *_args, **_kwargs: json.dumps(run), ) assert sched._direct_pr_run_still_superseded("owner/repo", 7, "93") is False @@ -10206,14 +10253,17 @@ def test_pr1669_direct_revalidation_allows_genuine_supersession(monkeypatch): monkeypatch.setattr( sched, "gh_api_json", - lambda path: { + lambda _path: {"state": "open", "draft": False, "head": {"sha": "b" * 40}}, + ) + monkeypatch.setattr( + sched, + "run_github_actions_for_repository", + lambda *_args, **_kwargs: json.dumps({ "event": "pull_request", "status": "in_progress", "head_sha": "a" * 40, "pull_requests": [{"number": 7}], - } - if "/actions/runs/" in path - else {"state": "open", "draft": False, "head": {"sha": "b" * 40}}, + }), ) assert sched._direct_pr_run_still_superseded("owner/repo", 7, "98") is True @@ -10276,12 +10326,15 @@ def test_pr1669_review_revalidation_handles_stale_and_current_heads(monkeypatch) } live_head = {"value": "b" * 40} - def fake_api(path): - if "/actions/runs/" in path: - return run + def fake_api(_path): return {"state": "open", "draft": False, "head": {"sha": live_head["value"]}} monkeypatch.setattr(sched, "gh_api_json", fake_api) + monkeypatch.setattr( + sched, + "run_github_actions_for_repository", + lambda *_args, **_kwargs: json.dumps(run), + ) assert sched._review_run_still_superseded( "owner/repo", "OpenCode Review", 7, "ContextualWisdomLab/.github", "95" ) is True @@ -10435,20 +10488,20 @@ def test_pr1669_strix_dispatch_preserves_candidate_that_is_current_after_revalid def test_pr1669_direct_revalidation_fails_closed_when_live_authority_is_unreadable(monkeypatch, capsys): """Direct cancellation must preserve the candidate when fresh authority cannot be read.""" - def fail_api(_path): + def fail_actions(*_args, **_kwargs): raise RuntimeError("simulated live-authority outage") - monkeypatch.setattr(sched, "gh_api_json", fail_api) + monkeypatch.setattr(sched, "run_github_actions_for_repository", fail_actions) assert sched._direct_pr_run_still_superseded("owner/repo", 7, "94") is False assert "Preserving workflow run 94 in owner/repo" in capsys.readouterr().out def test_pr1669_review_revalidation_fails_closed_when_live_authority_is_unreadable(monkeypatch, capsys): """Review cancellation must preserve the candidate when fresh authority cannot be read.""" - def fail_api(_path): + def fail_actions(*_args, **_kwargs): raise RuntimeError("simulated live-authority outage") - monkeypatch.setattr(sched, "gh_api_json", fail_api) + monkeypatch.setattr(sched, "run_github_actions_for_repository", fail_actions) assert sched._review_run_still_superseded( "owner/repo", "OpenCode Review", 7, "ContextualWisdomLab/.github", "95" ) is False @@ -10551,12 +10604,15 @@ def test_pr1669_opencode_open_draft_old_head_remains_cancellable(monkeypatch): "display_title": f"Required OpenCode Review owner/repo#7@{old_head}", } - def fake_api(path): - if "/actions/runs/" in path: - return run + def fake_api(_path): return {"state": "open", "draft": True, "head": {"sha": live_head}} monkeypatch.setattr(sched, "gh_api_json", fake_api) + monkeypatch.setattr( + sched, + "run_github_actions_for_repository", + lambda *_args, **_kwargs: json.dumps(run), + ) assert sched._review_run_still_superseded( "owner/repo", "OpenCode Review", 7, "ContextualWisdomLab/.github", "96" ) is True @@ -10572,12 +10628,15 @@ def test_pr1669_strix_open_draft_old_head_remains_cancellable(monkeypatch): "display_title": f"Strix Security Scan owner/repo#7@{old_head}", } - def fake_api(path): - if "/actions/runs/" in path: - return run + def fake_api(_path): return {"state": "open", "draft": True, "head": {"sha": live_head}} monkeypatch.setattr(sched, "gh_api_json", fake_api) + monkeypatch.setattr( + sched, + "run_github_actions_for_repository", + lambda *_args, **_kwargs: json.dumps(run), + ) assert sched._review_run_still_superseded( "owner/repo", "Strix Security Scan", 7, "ContextualWisdomLab/.github", "97" ) is True @@ -10790,3 +10849,79 @@ def behind_with(nodes): assert "checks are still queued or running" not in resumed.reason assert sched.has_in_flight_check_runs(behind_with([])) is False + + +def test_central_actions_inventory_uses_host_scoped_credentials(monkeypatch): + """Central run reads and cancellation cannot spend the cross-repository App quota.""" + calls = [] + + def fake_run_with_env(args, *, stdin=None, env=None): + calls.append((tuple(args), None if env is None else env.get("GH_TOKEN"))) + return '{"workflow_runs": []}' + + monkeypatch.setattr(sched, "run_with_env", fake_run_with_env) + monkeypatch.setenv("GH_TOKEN", "mutation-app-token") + monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "target-actions-token") + monkeypatch.setenv("SCHEDULER_DISPATCH_TOKEN", "central-runner-token") + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "contextualwisdomlab/.GITHUB", + ) + + sched.active_workflow_runs("ContextualWisdomLab/.github", statuses=("queued",)) + sched.force_cancel_workflow_runs("ContextualWisdomLab/.github", ["101"]) + sched.active_workflow_runs("owner/repo", statuses=("queued",)) + sched.force_cancel_workflow_runs("owner/repo", ["202"]) + + assert [token for _, token in calls] == [ + "central-runner-token", + "central-runner-token", + "target-actions-token", + "target-actions-token", + ] + + +@pytest.mark.parametrize( + ("selected_token", "workflow_token", "message"), + ( + ("", "workflow-runner-token", "is missing"), + ("selected-mutation-token", "", "comparison evidence is missing"), + ("workflow-runner-token", "workflow-runner-token", "resolved to"), + ), +) +def test_declared_workflow_starting_source_cannot_mask_runner_token_fallback( + monkeypatch, + selected_token, + workflow_token, + message, +): + """A declared App/PAT source cannot hide a missing or workflow-token fallback.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", selected_token) + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", workflow_token) + + assert not sched.head_mutation_credential_starts_workflows() + with pytest.raises(RuntimeError, match=message): + sched.require_workflow_starting_mutation_credential("update-branch") + + +def test_withheld_mutation_guidance_uses_recorded_reason_after_environment_changes( + monkeypatch, +): + """A recorded wait decision cannot be rewritten by later credential changes.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "github-token") + monkeypatch.setenv("GH_TOKEN", "workflow-runner-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") + reason = sched.non_triggering_head_mutation_reason("branch update") + + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "PR_REVIEW_MERGE_TOKEN") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + assert sched.head_mutation_credential_starts_workflows() + + decision = sched.Decision(7, "wait", reason) + guidance = sched.decision_guidance(decision) + assert guidance is not None + assert "workflow GITHUB_TOKEN" in guidance["summary"] + assert "workflow GITHUB_TOKEN" in "\n".join( + sched.head_mutation_credential_upgrade_summary([decision]) + ) diff --git a/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py b/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py index ba0b2598a9..3070e7ff21 100644 --- a/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py +++ b/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py @@ -51,10 +51,10 @@ def test_codeql_pr_uses_explicit_supported_image(self) -> None: 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.""" + """Require validation, scan, and attempt wake 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) + self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 3) def test_python_security_uses_explicit_supported_image(self) -> None: """Require all three Python Security jobs to pin Ubuntu 24.04.""" diff --git a/tests/test_scheduler_workflow_credential_invariant.py b/tests/test_scheduler_workflow_credential_invariant.py new file mode 100644 index 0000000000..48ccb9062a --- /dev/null +++ b/tests/test_scheduler_workflow_credential_invariant.py @@ -0,0 +1,15 @@ +"""Coverage for scheduler workflow-starting credential invariants.""" + +import pytest + +from scripts.ci import pr_review_merge_scheduler_core as scheduler_core + + +def test_withheld_mutation_reason_rejects_a_workflow_starting_credential(monkeypatch): + """Withheld-mutation text cannot be fabricated for an accepted credential.""" + monkeypatch.setenv("SCHEDULER_MUTATION_TOKEN_SOURCE", "opencode-app") + monkeypatch.setenv("GH_TOKEN", "selected-mutation-token") + monkeypatch.setenv("SCHEDULER_WORKFLOW_TOKEN", "workflow-runner-token") + + with pytest.raises(RuntimeError, match="requires a non-triggering mutation credential"): + scheduler_core.non_triggering_head_mutation_reason("update-branch") diff --git a/tests/test_stacked_pr_security_workflow_contract.py b/tests/test_stacked_pr_security_workflow_contract.py index 9ee655381b..3e73d53cdf 100644 --- a/tests/test_stacked_pr_security_workflow_contract.py +++ b/tests/test_stacked_pr_security_workflow_contract.py @@ -8,7 +8,12 @@ def test_security_workflows_run_for_stacked_pull_requests() -> None: """Required PR security workflows must not filter out feature bases.""" - for workflow_name in ("security-scan.yml", "sast-semgrep.yml"): + for workflow_name in ( + "security-scan.yml", + "sast-semgrep.yml", + "python-security.yml", + "agent-review-runtime-quality-ci.yml", + ): workflow = (REPO_ROOT / ".github" / "workflows" / workflow_name).read_text( encoding="utf-8" ) @@ -26,4 +31,7 @@ def test_security_workflows_run_for_stacked_pull_requests() -> None: "# Scan every PR base ref" in workflow or "# Do not restrict the base ref" in workflow ) - assert not any(line.strip().startswith("branches:") for line in pull_request_block) + assert not any( + line.strip().startswith(("branches:", "branches-ignore:")) + for line in pull_request_block + )