diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index c21c8446df..d748fd3b7b 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,219 @@ 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 + if [ "${handler_source_sha,,}" = "${PRODUCER_SOURCE_SHA,,}" ]; then + return 0 + fi + source_compare="$(gh api "repos/ContextualWisdomLab/.github/compare/${PRODUCER_SOURCE_SHA}...${handler_source_sha}" 2>/dev/null)" || return 1 + printf '%s' "$source_compare" | jq -e \ + --arg source "${PRODUCER_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 +494,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 +517,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 +551,241 @@ 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 + if [ "${handler_source_sha,,}" = "${PRODUCER_SOURCE_SHA,,}" ]; then + return 0 + fi + source_compare="$(gh api "repos/ContextualWisdomLab/.github/compare/${PRODUCER_SOURCE_SHA}...${handler_source_sha}" 2>/dev/null)" || return 1 + printf '%s' "$source_compare" | jq -e \ + --arg source "${PRODUCER_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 +797,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 +837,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,rerun_request:{mode:$rerun_mode,required_jobs:$required_jobs}}}' | GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input - diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml index c94fdf55c2..c9ffb8dc35 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -18,7 +18,8 @@ run-name: >- github.event.client_payload.pr_number || 'event' }}@${{ github.event.client_payload.pr_head_sha || github.sha }}/${{ github.event.client_payload.pr_base_sha || 'none' }}/${{ - github.event.client_payload.required_run_id || github.run_id }} + 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 @@ -148,7 +151,10 @@ jobs: SUPPLIED_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }} SUPPLIED_MATRIX: ${{ toJSON(github.event.client_payload.matrix) }} SUPPLIED_REQUIRED_RUN_ID: ${{ github.event.client_payload.required_run_id || '' }} - SUPPLIED_REQUIRED_JOBS: ${{ toJSON(github.event.client_payload.required_jobs) }} + SUPPLIED_REQUIRED_JOBS: ${{ toJSON(github.event.client_payload.rerun_request.required_jobs || github.event.client_payload.required_jobs) }} + SUPPLIED_RERUN_MODE: ${{ github.event.client_payload.rerun_request.mode || github.event.client_payload.rerun_mode || 'failed' }} + SUPPLIED_PRODUCER_SOURCE_SHA: ${{ github.event.client_payload.producer_source_sha || '' }} + WORKFLOW_SOURCE_SHA: ${{ github.workflow_sha }} # 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. @@ -182,6 +188,24 @@ jobs: 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}$ ]] || + ! [[ "$WORKFLOW_SOURCE_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::CodeQL producer source is missing or malformed." + exit 1 + fi + if [ "${SUPPLIED_PRODUCER_SOURCE_SHA,,}" != "${WORKFLOW_SOURCE_SHA,,}" ]; then + if ! source_compare="$(gh api "repos/ContextualWisdomLab/.github/compare/${SUPPLIED_PRODUCER_SOURCE_SHA}...${WORKFLOW_SOURCE_SHA}" 2>/dev/null)" || + ! printf '%s' "$source_compare" | jq -e \ + --arg source "${SUPPLIED_PRODUCER_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; then + echo "::error::CodeQL producer source is not an immutable ancestor of the current handler workflow source." + exit 1 + fi + 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)" @@ -203,7 +227,7 @@ jobs: if [ -z "$jobs_json" ] || [ "$(jq -n --argjson matrix "$matrix_json" --argjson jobs "$jobs_json" ' ($jobs | type == "array") - and (($jobs | length) == ($matrix | length)) + and (($jobs | length) >= ($matrix | length)) and ($jobs | all( (.language | type == "string") and (.language | test("^[a-z0-9-]+$")) @@ -212,14 +236,18 @@ jobs: or ((.job_id | type == "string") and (.job_id | test("^[1-9][0-9]*$"))) ) )) - and (($jobs | map(.language) | sort) == ($matrix | map(.language) | sort)) + and (((($matrix | map(.language)) - ($jobs | map(.language))) | length) == 0) and (($jobs | map(.language) | unique | length) == ($jobs | length)) ')" != "true" ]; then - printf '::error::CodeQL wake identity is missing, non-canonical, or does not match the dispatched languages one-to-one.\n' + printf '::error::CodeQL wake identity is missing, non-canonical, or is duplicate or does not cover every dispatched language.\n' exit 1 fi if ! [[ "$SUPPLIED_REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then - printf '::error::CodeQL wake identity is missing, non-canonical, or does not match the dispatched languages one-to-one.\n' + printf '::error::CodeQL wake identity is missing, non-canonical, or is duplicate or does not cover every dispatched language.\n' + exit 1 + fi + if [ "$SUPPLIED_RERUN_MODE" != "failed" ] && [ "$SUPPLIED_RERUN_MODE" != "all" ]; then + echo "::error::CodeQL rerun mode must be either failed or all." exit 1 fi jobs_json="$(printf '%s' "$jobs_json" | jq -c 'map({language, job_id: (.job_id | tonumber)})')" @@ -265,6 +293,8 @@ jobs: printf '%s\n' "$matrix_json" echo "EOF" printf 'required_run_id=%s\n' "$SUPPLIED_REQUIRED_RUN_ID" + printf 'rerun_mode=%s\n' "$SUPPLIED_RERUN_MODE" + printf 'producer_source_sha=%s\n' "$SUPPLIED_PRODUCER_SOURCE_SHA" echo "required_jobs<"$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,57 +567,131 @@ 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 + wake-required: + name: Wake verified CodeQL required jobs + 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 + steps: + - name: Settle exact CodeQL required run if: >- always() - && steps.publish_status.outcome == 'success' && needs.validate-dispatch.outputs.target_repository != '' && needs.validate-dispatch.outputs.pr_number != '' && needs.validate-dispatch.outputs.head_sha != '' && needs.validate-dispatch.outputs.required_run_id != '' && needs.validate-dispatch.outputs.required_jobs != '' env: - GH_TOKEN: ${{ needs.validate-dispatch.outputs.target_repository == github.repository && github.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} + 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 || '' }} 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_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 }} + PRODUCER_RUN_ID: ${{ github.run_id }} + HANDLER_REPOSITORY: ${{ github.repository }} run: | set -euo pipefail - if [ -z "${GH_TOKEN:-}" ] || [ "$WAKE_TOKEN_SOURCE" = "unavailable" ]; then + if [ -z "${PR_REVIEW_MERGE_WAKE_TOKEN:-}" ] && + [ -z "${OPENCODE_APPROVE_WAKE_TOKEN:-}" ] && + [ -z "${GITHUB_WAKE_TOKEN:-}" ]; then echo "::error::Actions-capable CodeQL wake credential is unavailable." exit 1 fi - REQUIRED_JOB_ID="$(printf '%s' "$REQUIRED_JOBS" | jq -r --arg lang "$REQUIRED_LANGUAGE" ' - [.[] | select(.language == $lang) | .job_id | tostring] - | if length == 1 and (.[0] | test("^[1-9][0-9]*$")) then .[0] else empty end - ')" + + run_api() { + token_label="$1" + token="$2" + shift 2 + [ -n "$token" ] || return 1 + if GH_TOKEN="$token" gh api "$@"; then + echo "::notice::CodeQL wake API used ${token_label}." >&2 + return 0 + fi + echo "::notice::CodeQL wake API using ${token_label} did not succeed." >&2 + return 1 + } + + github_api() { + 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 ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$REQUIRED_JOB_ID" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$REQUIRED_LANGUAGE" =~ ^[a-z0-9-]+$ ]]; then + { [ "$RERUN_MODE" != "failed" ] && [ "$RERUN_MODE" != "all" ]; } || + ! [[ "$BASE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || + ! [[ "$PRODUCER_SOURCE_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || + [ "$(printf '%s' "$REQUIRED_JOBS" | jq -r ' + type == "array" and length >= 1 and all( + (.language | type == "string" and test("^[a-z0-9-]+$")) + and (.job_id | type == "number" and . >= 1 and . == floor) + ) + and ((map(.language) | unique | length) == length) + and ((map(.job_id) | unique | length) == length) + ' 2>/dev/null || true)" != "true" ]; then echo "::error::CodeQL wake identity is non-canonical." exit 1 fi - pull="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + pull="$(github_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')" + live_base_repository="$(printf '%s' "$pull" | jq -r '.base.repo.full_name // empty')" + live_base_ref="$(printf '%s' "$pull" | jq -r '.base.ref // empty')" + live_base="$(printf '%s' "$pull" | jq -r '.base.sha // empty')" if [ "$live_state" != "open" ] || [ "$live_head" != "$HEAD_SHA" ]; then echo "::error::CodeQL wake rejected a closed PR or stale head." exit 1 fi + if [ "$live_base_repository" != "$TARGET_REPOSITORY" ] || + [ "$live_base_ref" != "$BASE_REF" ] || + ! [[ "$live_base" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "::error::CodeQL wake rejected malformed or retargeted live base metadata." + exit 1 + fi + late_base_advance=false + if [ "$live_base" != "$BASE_SHA" ]; then + base_compare="$(github_api "repos/${TARGET_REPOSITORY}/compare/${BASE_SHA}...${live_base}" 2>/dev/null)" || { + echo "::error::CodeQL wake could not prove a forward base advance." + exit 1 + } + if ! printf '%s' "$base_compare" | jq -e \ + --arg base "${BASE_SHA,,}" ' + .status == "ahead" + and .behind_by == 0 + and ((.base_commit.sha // "" | ascii_downcase) == $base) + and ((.merge_base_commit.sha // "" | ascii_downcase) == $base) + ' >/dev/null; then + echo "::error::CodeQL wake rejected a non-forward base advance." + exit 1 + fi + late_base_advance=true + RERUN_MODE=all + echo "::notice::Protected base advanced during the dispatched scan; the exact required run will restart against ${live_base}." + fi - run="$(gh api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}")" + run="$(github_api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}")" run_identity="$(printf '%s' "$run" | jq -r --arg head "$HEAD_SHA" --argjson run_id "$REQUIRED_RUN_ID" ' select(.id == $run_id) | select(.event == "pull_request") @@ -564,21 +699,267 @@ jobs: | 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." + if [ "$run_identity" != "$REQUIRED_RUN_ID" ]; then + echo "::error::CodeQL wake rejected missing or ambiguous exact run identity." exit 1 fi - gh api -X POST "repos/${TARGET_REPOSITORY}/actions/jobs/${REQUIRED_JOB_ID}/rerun" >/dev/null - echo "Re-ran exact failed CodeQL job ${REQUIRED_JOB_ID} for ${REQUIRED_LANGUAGE} on ${HEAD_SHA}." + original_jobs='[]' + while IFS= read -r required_job; do + language="$(printf '%s' "$required_job" | jq -r '.language')" + required_job_id="$(printf '%s' "$required_job" | jq -r '.job_id | tostring')" + expected_name="CodeQL compatibility analysis (${language})" + job="$(github_api "repos/${TARGET_REPOSITORY}/actions/jobs/${required_job_id}")" + job_identity="$(printf '%s' "$job" | jq -c \ + --arg head "$HEAD_SHA" --arg name "$expected_name" --arg language "$language" \ + --argjson run_id "$REQUIRED_RUN_ID" --argjson job_id "$required_job_id" \ + --arg rerun_mode "$RERUN_MODE" ' + select(.id == $job_id) + | select(.run_id == $run_id) + | select(.head_sha == $head) + | select(.name == $name) + | select( + .status == "completed" + and ( + ($rerun_mode == "failed" and .conclusion == "failure") + or ($rerun_mode == "all" and (.conclusion == "success" or .conclusion == "failure")) + ) + ) + | select((.run_attempt | type) == "number" and .run_attempt >= 1) + | {language:$language, job_id:.id, run_attempt:.run_attempt} + ')" + if [ -z "$job_identity" ]; then + echo "::error::CodeQL wake rejected missing or ambiguous exact run/job identity." + exit 1 + fi + original_jobs="$(jq -c --argjson job "$job_identity" '. + [$job]' <<<"$original_jobs")" + done < <(printf '%s' "$REQUIRED_JOBS" | jq -c '.[]') + + if [ "$late_base_advance" = false ]; then + expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}@${HEAD_SHA}/${BASE_SHA}/${REQUIRED_RUN_ID}/${PRODUCER_SOURCE_SHA}" + producer_run="$(github_api "repos/${HANDLER_REPOSITORY}/actions/runs/${PRODUCER_RUN_ID}")" + handler_source_is_compatible() { + candidate_source_sha="$1" + [[ "$candidate_source_sha" =~ ^[0-9a-fA-F]{40}$ ]] || return 1 + if [ "${candidate_source_sha,,}" = "${PRODUCER_SOURCE_SHA,,}" ]; then + return 0 + fi + source_compare="$(github_api "repos/ContextualWisdomLab/.github/compare/${PRODUCER_SOURCE_SHA}...${candidate_source_sha}" 2>/dev/null)" || return 1 + printf '%s' "$source_compare" | jq -e \ + --arg source "${PRODUCER_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 + } + handler_source_sha="$(printf '%s' "$producer_run" | jq -r '.head_sha // empty')" + if ! handler_source_is_compatible "$handler_source_sha"; then + echo "::error::CodeQL settlement rejected a handler outside the immutable producer-source ancestry." + exit 1 + fi + 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 + echo "::error::CodeQL settlement rejected the current handler run provenance." + exit 1 + fi + producer_jobs="$(github_api --paginate --slurp "repos/${HANDLER_REPOSITORY}/actions/runs/${PRODUCER_RUN_ID}/jobs?filter=latest&per_page=100")" + + direct_evidence_proven() { + language="$1" + 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" ] || return 1 + job_attempt="$(printf '%s' "$direct" | jq -r '.attempt')" + artifact_name="codeql-dispatch-${language}-${PRODUCER_RUN_ID}-${job_attempt}" + artifacts="$(github_api --paginate --slurp "repos/${HANDLER_REPOSITORY}/actions/runs/${PRODUCER_RUN_ID}/artifacts?name=${artifact_name}&per_page=100")" || return 1 + printf '%s' "$artifacts" | jq -e --arg name "$artifact_name" ' + [.[]?.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' >/dev/null + } + + receipt_evidence_proven() { + language="$1" + receipt_evidence='[]' + while IFS= read -r candidate; do + creator="$(jq -r '.creator.login // "" | ascii_downcase' <<<"$candidate")" + state="$(jq -r '.state // empty' <<<"$candidate")" + case "$creator" in + opencode-agent|opencode-agent\[bot\]) ;; + github-actions\[bot\]) + [ "${TARGET_REPOSITORY,,}" = "contextualwisdomlab/.github" ] || continue + [ "${HANDLER_REPOSITORY,,}" = "contextualwisdomlab/.github" ] || continue + ;; + *) continue ;; + esac + target_url="$(jq -r '.target_url // empty' <<<"$candidate")" + receipt_run_id="${target_url##*/}" + [[ "$receipt_run_id" =~ ^[1-9][0-9]*$ ]] || continue + receipt_run="$(github_api "repos/${HANDLER_REPOSITORY}/actions/runs/${receipt_run_id}" 2>/dev/null)" || continue + receipt_source_sha="$(jq -r '.head_sha // empty' <<<"$receipt_run")" + handler_source_is_compatible "$receipt_source_sha" || continue + if ! jq -e --argjson run_id "$receipt_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]") + ' <<<"$receipt_run" >/dev/null; then + continue + fi + receipt_jobs="$(github_api --paginate --slurp "repos/${HANDLER_REPOSITORY}/actions/runs/${receipt_run_id}/jobs?filter=latest&per_page=100" 2>/dev/null)" || continue + receipt_attempt="$(jq -r --arg name "CodeQL dispatch scan (${language})" --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 + ' <<<"$receipt_jobs")" + [[ "$receipt_attempt" =~ ^[1-9][0-9]*$ ]] || continue + artifact_name="codeql-dispatch-${language}-${receipt_run_id}-${receipt_attempt}" + receipt_artifacts="$(github_api --paginate --slurp "repos/${HANDLER_REPOSITORY}/actions/runs/${receipt_run_id}/artifacts?name=${artifact_name}&per_page=100" 2>/dev/null)" || continue + if jq -e --arg name "$artifact_name" ' + [.[]?.artifacts[]? | select(.name == $name and .expired == false)] | length == 1 + ' <<<"$receipt_artifacts" >/dev/null; then + receipt_evidence="$( + jq -c --argjson run_id "$receipt_run_id" --arg state "$state" \ + '. + [{run_id:$run_id,state:$state}] | unique_by([.run_id,.state])' \ + <<<"$receipt_evidence" + )" + fi + done < <(jq -c \ + --arg ctx "codeql-dispatch/${language}/${BASE_SHA}" \ + --arg receipt "cwl1;h=${HEAD_SHA};w=codeql-scan-dispatch;r=${REQUIRED_RUN_ID};s=${PRODUCER_SOURCE_SHA}" ' + .[][] + | 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]*$")) + ' <<<"$statuses") + [ "$(jq 'length' <<<"$receipt_evidence")" -eq 1 ] + } + + statuses="$(github_api --paginate --slurp "repos/${TARGET_REPOSITORY}/commits/${HEAD_SHA}/statuses?per_page=100")" + missing_receipts='[]' + while IFS= read -r required_job; do + language="$(printf '%s' "$required_job" | jq -r '.language')" + if ! receipt_evidence_proven "$language" && ! direct_evidence_proven "$language"; then + missing_receipts="$(jq -c --arg language "$language" '. + [$language]' <<<"$missing_receipts")" + fi + done < <(printf '%s' "$REQUIRED_JOBS" | jq -c '.[]') + if [ "$(jq 'length' <<<"$missing_receipts")" -gt 0 ]; then + echo "::notice::CodeQL exact-run settlement is waiting for authenticated terminal receipts: ${missing_receipts}." + exit 0 + fi + fi + + settlement_proven() { + all_jobs="$1" + while IFS= read -r original_job; do + language="$(printf '%s' "$original_job" | jq -r '.language')" + original_job_id="$(printf '%s' "$original_job" | jq -r '.job_id')" + original_attempt="$(printf '%s' "$original_job" | jq -r '.run_attempt')" + expected_name="CodeQL compatibility analysis (${language})" + settled_count="$(printf '%s' "$all_jobs" | jq \ + --arg head "$HEAD_SHA" --arg name "$expected_name" \ + --argjson run_id "$REQUIRED_RUN_ID" \ + --argjson original_job_id "$original_job_id" \ + --argjson original_attempt "$original_attempt" ' + [ + .jobs[]? + | select(.id != $original_job_id) + | select(.run_id == $run_id) + | select(.head_sha == $head) + | select(.name == $name) + | select((.run_attempt | type) == "number" and .run_attempt > $original_attempt) + | select( + .status == "queued" + or .status == "in_progress" + or (.status == "completed" and (.conclusion == "success" or .conclusion == "failure")) + ) + ] | length + ')" + [ "$settled_count" -ge 1 ] || return 1 + done < <(printf '%s' "$original_jobs" | jq -c '.[]') + } + + run_status="$(printf '%s' "$run" | jq -r '.status // empty')" + run_conclusion="$(printf '%s' "$run" | jq -r '.conclusion // empty')" + if [ "$run_status" != "completed" ] || [ "$run_conclusion" != "failure" ]; then + all_jobs="$(github_api --paginate "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs?filter=all&per_page=100" --jq '.jobs[]' | jq -s '{jobs:.}')" + if settlement_proven "$all_jobs"; then + echo "CodeQL exact-run settlement already has exact newer attempts for every required language." + exit 0 + fi + echo "::error::CodeQL required run is not a completed failure and exact newer attempts are not proven." + exit 1 + fi + + latest_jobs="$(github_api --paginate "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs?filter=latest&per_page=100" --jq '.jobs[]' | jq -s '{jobs:.}')" + required_job_ids="$(printf '%s' "$REQUIRED_JOBS" | jq -c '[.[].job_id] | sort')" + unexpected_failed_job_ids="$(printf '%s' "$latest_jobs" | jq -c --argjson required "$required_job_ids" '[.jobs[]? | select(.status == "completed" and .conclusion == "failure") | select(.id as $id | $required | index($id) == null) | .id] | sort')" + if [ "$(jq 'length' <<<"$unexpected_failed_job_ids")" -ne 0 ]; then + echo "::error::CodeQL run-wide settlement rejected failed jobs outside the exact language map." + exit 1 + fi + if [ "$RERUN_MODE" = "all" ]; then + rerunnable_job_ids="$(printf '%s' "$latest_jobs" | jq -c --argjson required "$required_job_ids" '[.jobs[]? | select(.id as $id | $required | index($id) != null) | select(.status == "completed" and (.conclusion == "success" or .conclusion == "failure")) | .id] | sort')" + wake_endpoint="rerun" + else + rerunnable_job_ids="$(printf '%s' "$latest_jobs" | jq -c '[.jobs[]? | select(.status == "completed" and .conclusion == "failure") | .id] | sort')" + wake_endpoint="rerun-failed-jobs" + fi + if [ "$rerunnable_job_ids" != "$required_job_ids" ]; then + echo "::error::CodeQL run-wide settlement rejected a non-terminal or incomplete exact language map." + exit 1 + fi + + wake_error="$(mktemp)" + if github_api -X POST "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/${wake_endpoint}" >/dev/null 2>"$wake_error"; then + rm -f "$wake_error" + echo "Requested ${RERUN_MODE} CodeQL rerun for exact run ${REQUIRED_RUN_ID} on ${HEAD_SHA}." + exit 0 + fi + + wake_summary="$(head -n 1 "$wake_error" | tr -d '\r' || true)" + rm -f "$wake_error" + all_jobs="$(github_api --paginate "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs?filter=all&per_page=100" --jq '.jobs[]' | jq -s '{jobs:.}')" + if settlement_proven "$all_jobs"; then + echo "CodeQL exact-run settlement observed exact newer attempts for every required language after a concurrent wake." + exit 0 + fi + echo "::error::CodeQL run-wide wake failed and could not prove exact newer attempts for every required language: ${wake_summary:-unknown error}." + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index bf192f6a9e..f19c64e1da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,115 @@ +### CodeQL verdicts unify authenticated status and direct evidence + +- Shard and coordinator settlement now enumerate both authenticated status + receipts and status-less direct run/SARIF evidence before deciding. The two + channels are normalized by exact producer run ID and state: zero candidates + remains pending, one candidate supplies the verdict, and multiple or + conflicting candidates fail closed with redaction-safe telemetry before a + token request or another dispatch. A valid status from producer A can no + longer hide a distinct complete direct producer B. + +### CodeQL wake credentials retain bounded fallback + +- The single run-wide settlement now tries the two configured Actions-write + credentials in order and uses the native token only for a self-repository + target. A present but repository-denied primary credential can no longer + shadow a working fallback. Every identity read and the final exact-run wake + share the same bounded chain; exhaustion remains fail-closed, and the scan + job's repository-scoped App token is never transferred to the separate wake + job. + +### CodeQL dispatch payload respects GitHub cardinality + +- The current-head coordinator had grown to eleven top-level `client_payload` + properties, so GitHub rejected the real repository dispatch with HTTP 422 + before the central scan could start. The sender now groups rerun mode and + exact failed-job identities under one `rerun_request` object, keeping the + payload at GitHub's ten-property limit. The protected receiver reads the + nested contract first and retains legacy-field compatibility for already + queued dispatches. RED run `34217639402` reproduced `11 <= 10` on PR #1902. + +### CodeQL attempts share one live base and settle predecessor receipts + +- `detect-languages` now captures one validated live base SHA before matrix + expansion. Every shard and the coordinator consume that immutable attempt + output. A later protected-base advance makes each shard fail closed, while + the coordinator binds a new dispatch to the refreshed base and asks the + trusted handler to restart the whole required workflow attempt. The + successful capture job and every matrix shard therefore rerun together; + failed-job-only recovery remains the default when the base is unchanged. +- The handler repeats the same live head and base-ref check immediately before + settlement. If the protected base advances after dispatch validation while + the scan is running, settlement proves the old base is the merge-base + ancestor of the new base and promotes that exact run to a whole-attempt rerun + instead of leaving the unchanged pull request permanently red. +- Run-wide settlement now re-authenticates exact predecessor-handler receipts + through run metadata, immutable source ancestry, exactly one successful + `validate-dispatch` job, language result, SARIF preservation, exactly one + Medium+ gate whose conclusion matches the published state, and the + unexpired exact-attempt artifact. Shard, + coordinator, and settlement consumers apply the same gate-state contract. + A mixed matrix may therefore reuse a completed language while the current + handler scans only pending languages; ambiguous, contradictory, or + incomplete receipts remain fail-closed. Multiple evidence-complete receipt + or direct-run candidates are a terminal ambiguity for that coordinator + attempt; it logs the exact run IDs and states and does not request a token or + dispatch another producer into the ambiguous set. +- The trusted handler now revalidates the target base immediately before it + wakes the required workflow. A same-repository, same-ref, strict forward + advance is proven through GitHub compare evidence and restarts the exact + required run in whole-run mode without consuming old-base receipts. A + retarget, rewrite, divergence, stale head, or malformed comparison remains + fail-closed. + +### CodeQL queued runs rebind to live base and reject receipt ambiguity + +- Before matrix expansion, a required CodeQL attempt validates the live + repository, base ref, head, and current base SHA. Status lookup, dispatch + payload, handler title, and receipt all use that one captured base. This + avoids the stale-event deadlock without allowing sibling shards to adopt + different base revisions. +- Shard and coordinator receipt consumers authenticate every matching App or + narrow self-repository candidate before deciding. Exactly one unique + evidence-complete run/state is required; conflicting complete receipts fail + closed instead of letting status order choose the verdict. Repeated rows for + the same run/state normalize to one candidate. + +### CodeQL App receipts require exact dispatch evidence + +- App-created statuses now pass through the same immutable producer run, source + ancestry, exact title and actors, unique successful dispatch validation, + language gate, SARIF preservation, and unexpired run-attempt artifact proof + as the narrow self-repository fallback. Creator identity alone is not a + terminal verdict. + +### CodeQL producer sources survive compatible handler advances + +- A required CodeQL run now keeps its immutable producer source `S` when the + `repository_dispatch` receiver runs from a newer default-branch handler `T`. + Receiver admission, shard and coordinator evidence reads, and run-wide + settlement require either `S == T` or GitHub compare evidence that `S` is the + exact merge base of `T`, with `T` ahead and not behind. Divergent, missing, + malformed, or unverifiable sources remain fail-closed; the target PR base is + still an independent identity. Executable RED fixtures cover the pre-fix + `S != T` deadlock and the negative divergent-source boundary. + +### CodeQL duplicate handlers are filtered by complete evidence + +- Shard and coordinator consumers no longer reject every direct verdict merely + because an incomplete predecessor and its retry share the same authenticated + dispatch title. They validate each candidate's immutable run metadata, + source ancestry, exact language gate, successful SARIF preservation, and + unexpired exact-run artifact first, then accept exactly one evidence-complete + candidate. Zero or multiple complete candidates remain fail-closed. + +### CodeQL direct evidence reads every producer job and artifact page + +- Shard, coordinator, and run-wide settlement consumers now stream every producer job and artifact page with GitHub CLI native pagination before rebuilding the response object consumed by the existing exact-identity filters. RED commit `86898d3ecccdf8306d8dc42c8f9e7d5ee8dfbc3a` enumerates all five collection pairs so a future first-page regression fails closed. + +### Mixed CodeQL verdicts retain complete run-wide settlement identity + +- The CodeQL coordinator still uses authenticated terminal receipts to decide whether any new scan is needed, but when one language remains pending it dispatches the complete exact failed-job language matrix. GitHub's `rerun-failed-jobs` endpoint wakes the whole failed set, so the handler requires a one-to-one matrix/job map; a pending-only matrix could never prove the newer attempt for an omitted failed sibling. RED commits `e25800f01c18ec8b28bd31b720478fc810cc4e92` and `1c84729` reproduce the settlement deadlock and the incomplete wake envelope; PR #1902 remains Proposed until its current head receives independent review and exact-head Checks. + ### 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 +180,38 @@ - 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] +- Bind every CodeQL dispatch and receipt to the exact base SHA and required-run + ID, and move run-wide settlement out of the language matrix into one + non-matrix job. Scan shards now keep `actions: read`; only the settlement + job receives `actions: write`. When target status publication is forbidden, + consumers may settle from the uniquely matched central run only after + revalidating its workflow, actors, title, live PR identity, successful + validation and SARIF upload, terminal language gate, and exact unexpired + run/attempt artifact across complete paginated run, job, and artifact + responses. A status receipt or this direct evidence must exist; + neither URL shape nor a bare HTTP 403 is sufficient. +- Authenticate the CodeQL handler's `.github` self-repository status fallback. + If the target-scoped App status POST returns 403 and the handler's own token + publishes as `github-actions[bot]`, consumers now require the exact protected + repository-dispatch run, target/PR/head run title, language job conclusion, + and unexpired run/attempt SARIF artifact. Other repositories still require + the OpenCode App creator; a bot creator or central-looking URL alone cannot + satisfy the gate. +- Settle multi-language CodeQL callbacks at the exact required-run boundary. + The native handler now waits for every base/head/workflow-bound language + receipt, keeps the pending scan matrix separate from the complete failed + compatibility-job settlement map, rejects unrelated failed jobs, + and calls `rerun-failed-jobs` once. A concurrent wake is accepted only when + newer attempts for every mapped language are proven. Required-workflow + reruns may also redispatch when complete receipt history proves the earlier + attempt never reached the coordinator; `run_attempt` is no longer treated + as a dispatch receipt. +- Bind CodeQL admission to the immutable central workflow source SHA. + Required workflows now carry `github.workflow_sha` through dispatch payload, + handler title, terminal receipt, and exact-run validation. This source SHA is + independent from the target pull request base SHA: target-base movement does + not rewrite it, while a missing, substituted, or conflicting source fails + closed. - Include merge-scheduler entrypoint, core, and regression-test changes in the existing runtime-quality workflow's trigger and suite selector. Scheduler workflow edits retain queue checks and also select the full review-repair diff --git a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md index 5a11894767..579a9ef755 100644 --- a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md +++ b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md @@ -1,6 +1,6 @@ # 0025 — Restore central CodeQL as a required workflow via repository_dispatch -**Status:** Proposed, amended 2026-09-07 (one dispatch per pull request; language independence is the handler job matrix) · **Date:** 2026-09-03 · **Owner intent recorded:** loop-brief item 41 +**Status:** Proposed, amended 2026-09-08 (one dispatch and exact-run settlement per pull request; language independence is the handler job matrix) · **Date:** 2026-09-03 · **Owner intent recorded:** loop-brief item 41 ## Problem @@ -99,26 +99,25 @@ 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 - attempt the shard reads the authenticated - current-head status once and reflects it as - this job's own exit code. - dispatch-current-head -- NEW: needs analyze-head, runs on attempt one - of an open current-head PR after the shards + pending to release the runner. On a settled + later attempt the shard reads the + authenticated current-head/current-base + status once and reflects it as this job's own + exit code. + dispatch-current-head -- NEW: needs analyze-head, runs for an open + current-head PR after the shards have job ids. Collects those ids from this run's jobs API, POSTs event_type codeql-scan once with the remaining language matrix and required_jobs: [{language, job_id}, ...], and fails closed if any shard job id is missing. Skips the POST when every language already - has a terminal verdict. github.run_attempt == 1 - is required: a single-job wake re-runs - dependents, and a second POST would cancel - the in-flight multi-language handler. + has a terminal verdict. Later workflow + attempts repeat this evidence test instead of + treating run_attempt as a dispatch receipt. .github/workflows/codeql-scan-dispatch.yml (NEW, runs natively in .github, NOT admitted through the ruleset, so codeql-action is unrestricted here) @@ -147,23 +146,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/error, description binding the + exact head and workflow, target_url pointing + at this .github run's own log for full evidence. -- Upload the SARIF as an artifact on this .github-side run for audit trail (mirrors strix.yml's "Preserve CodeQL SARIF evidence" / artifact retention today). - -- Re-fetch the open PR, exact required workflow - run, and exact failed language job; - require matching path/head/run/job/name before - calling the single-job rerun endpoint. Missing, - stale, closed, or mismatched identity fails - closed and leaves the required job failed. + -- After every language has a trusted terminal + receipt, re-fetch the open PR, exact required + workflow run, and every exact failed language + job. Require the failed-job set to equal the + 1:1 language map before calling the exact run's + rerun-failed-jobs endpoint. A concurrent wake + counts only after newer attempts for every + mapped job are proved. Missing, stale, closed, + extra-failed, or mismatched identity fails closed. ``` ### Concurrency identity is per pull request; language independence is the job matrix @@ -177,9 +179,11 @@ 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 analyzes and preserves its run/attempt SARIF artifact +with `actions: read`. A single non-matrix settlement job runs after all shards +and alone receives `actions: write`; it validates every language before it can +change the shared required run. One language's failure cannot cancel or skip a +sibling, and no matrix shard independently changes shared run state. #### 2026-09-07 amendment: one dispatch per pull request, adopted for the 60-job ceiling @@ -204,12 +208,121 @@ 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 +jobs in one run, not runs in one concurrency group. `required_jobs` is a 1:1 +map of language to canonical job id, and a missing, stale, or mismatched +identity still fails closed. The old scalar `required_job_id`/`required_language` payload is retired. +#### 2026-09-08 amendment: exact-run settlement resolves the two-language wake race + +Central handler runs `34077342761` (actions) and `34077321864` (Python) both +passed their finding, SARIF-preservation, and base-bound publication gates for +required run `34071540279`. Actions woke job `101632671065`; Python then tried +to wake job `101632672530` and GitHub returned HTTP 403 because the shared run +was already running. Per-job callbacks therefore could not converge. + +The selected repair uses one non-matrix settlement job after every mapped +language has terminated. It validates every original failed job plus the +required run path/head, rejects any failed job outside that exact map, and +then reruns failed jobs on that exact run. Authenticated receipts determine +whether any scan remains pending. Once one does, the dispatch matrix and +`required_jobs` both contain the complete failed compatibility-job set because +GitHub's run-wide `rerun-failed-jobs` endpoint wakes that complete set. A +trusted receipt can suppress dispatch only when every language is terminal; +it cannot remove one failed sibling from the exact wake envelope. The handler +therefore requires a one-to-one language/job map. If a concurrent settlement wins, +the loser succeeds only after the jobs API proves a newer attempt for every +mapped language; a bare 403 is still failure. Issuing an unbound run-wide +rerun, accepting `already running` without evidence, polling, and restoring +per-language dispatch runs were rejected because they respectively broaden +authority, lose the callback, occupy runners, or recreate the 60-job ceiling. + +#### 2026-09-08 amendment: self-repository status fallback has run provenance + +When the target is `ContextualWisdomLab/.github`, the OpenCode App token can +complete the scan but receive HTTP 403 while publishing the commit status. +The handler's own `GITHUB_TOKEN` may publish that self-repository status as +`github-actions[bot]`; accepting that creator globally would let any status +writer forge the context and is forbidden. + +The narrow fallback is accepted only for the `.github` target and handler. +Every receipt description carries the exact required-run ID. The consumer +resolves the numeric central run URL and verifies the unique +`repository_dispatch` workflow path, protected `main` source SHA, app actor and +triggering actor, generated run title bound to target/PR/head/base/required run, +the successful validation job, the terminal language gate, and its successful +SARIF upload plus unexpired exact run/attempt artifact. The handler's settlement +step may accept its own current-run receipt because it executes inside that +already-authenticated run. If every status POST is forbidden, the same complete +current-run evidence is sufficient without a receipt; this preserves fail-closed +identity while avoiding a circular dependency on `statuses:write`. Every other +target still requires either an OpenCode App receipt or that exact direct +evidence. Run discovery, exact job proof, and exact artifact proof consume every +paginated response; the first 100 objects are not an evidence boundary. Missing +or mismatched provenance remains pending/failure; creator, +URL, or a bare HTTP 403 alone is never enough. + +The verification above applies equally to an OpenCode App receipt. App creator +identity admits a candidate for validation; it does not replace producer +evidence. The candidate must contain exactly one completed, successful +`validate-dispatch` job before its language gate, SARIF preservation, and +artifact can authorize a verdict. This prevents a correctly authenticated but +unvalidated, premature, or misbound status from becoming terminal evidence. +The `github-actions[bot]` path retains its additional self-repository +restriction. + +A retry may create more than one handler run with the same bound title. Shard +and coordinator consumers therefore do not use title-count uniqueness as +evidence. They fully authenticate every candidate's run metadata, source +ancestry, exact language gate, SARIF preservation, and unexpired run/attempt +artifact, then require exactly one evidence-complete candidate. An incomplete +predecessor cannot hide its complete successor; two complete candidates remain +ambiguous and fail closed. + +The target pull request base SHA (`A`) and central handler workflow source SHA +(`S`) are separate identities. `A` binds the result to the target review base; +`S` is the immutable `github.workflow_sha` of the required workflow that made +the dispatch. The producer passes `S` in the payload and binds it into the +handler title and terminal receipt. Because `repository_dispatch` selects its +receiver from the default branch, handler runtime source `T` can advance after +the required run fixed `S`. Admission and every direct-evidence consumer accept +either `S == T` or GitHub compare evidence that `S` is the exact merge base of +`T`, `T` is ahead, and it is not behind. This keeps the immutable producer +identity while allowing a later protected-main receiver to preserve the +validated payload contract. Divergent, reversed, missing, malformed, or +unverifiable ancestry fails closed. Moving either repository's `main` ref after +run creation cannot substitute for the immutable run `head_sha`; comparison is +between the two recorded commit objects. Run 34186647327 returned an empty +`referenced_workflows` array, so that optional field is deliberately excluded +from source authority. + +If protected target base `A` advances while an unchanged PR head waits for a +runner, the event SHA is stale and no `synchronize` event is guaranteed. +`detect-languages` therefore re-fetches and validates the live repository, base +ref, base SHA, and head once before matrix expansion. It publishes that live +SHA as attempt identity `A`; every shard and the coordinator use the same +output. Each consumer revalidates that the live base still equals `A` before +reading or issuing evidence. A later advance invalidates the whole attempt +instead of allowing independently scheduled siblings to mix base revisions. +This is not evidence reuse: a status bound to the old `A` cannot match the new +attempt. Repository, ref, or head changes and malformed identity fail closed. + +The handler repeats this validation immediately before waking the required +workflow because the scan itself opens a second base-advance window. If the +same target repository and base ref moved strictly forward from `A`, GitHub +compare must report `ahead`, zero commits behind, and `A` as both base commit +and merge base. Only then may the handler skip old-base receipts and restart +the exact required run in whole-run mode. A retarget, rewrite, divergence, +stale head, or malformed comparison fails closed. + +Status ordering is likewise not an authority boundary. Consumers validate all +candidates and require exactly one unique evidence-complete run/state, matching +the direct-evidence uniqueness rule. Repeated rows for one run/state normalize +to one producer. Two distinct complete producers are ambiguous and fail closed +without requesting a credential or dispatching another producer into the +ambiguous set. Redaction-safe telemetry lists only exact candidate run IDs and +validated states; an incomplete predecessor does not hide one complete successor. + ## Scope decision: `analyze-merge` is dropped, not migrated `analyze-merge` ("CodeQL merge preview") is confirmed, per PR #1766's own @@ -252,6 +365,110 @@ blocker for this one. passing status. `strix.yml`'s manual-status-publish step already documents a similar concern; follow its precedent rather than trusting context name alone. +- **Run-wide rerun authority:** `rerun-failed-jobs` is allowed only when the + required run is the exact pull-request run/path/head, every mapped original + failed compatibility job remains in the settlement map even when its + language already has a trusted receipt, every pending language maps to an + exact failed job, every language has either a trusted + head/base/workflow/required-run receipt or exact validated central-run gate + and artifact evidence, and the complete failed-job set equals that map. A + concurrent call is accepted only with exact newer-attempt evidence. Only the + one non-matrix settlement job has `actions: write`. +- **Attempt-wide base identity:** `detect-languages` reads the live PR once + before matrix expansion and exports that base SHA. Every shard and the + coordinator use the same output; any later live-base movement invalidates + the whole attempt instead of letting independently queued shards adopt + different bases. +- **Mixed-handler receipt continuity:** a terminal language receipt may point + to an earlier handler for the same exact repository/PR/head/base/required + run/source tuple. Settlement revalidates that handler's immutable run, + source ancestry, language conclusion, SARIF-preservation step, and exact + unexpired artifact before combining it with current-handler direct evidence. + Zero or multiple evidence-complete receipts remain fail-closed. +- **Central source authority:** the payload, handler title, and receipt agree on + immutable producer source `S`; the exact handler run records runtime source + `T`. Every consumer requires `S == T` or exact GitHub compare proof that `S` + is `T`'s merge base and `T` is strictly ahead without being behind. Neither + identity is inferred from target base `A`, a mutable branch tip, or optional + `referenced_workflows` metadata. + +### 2026-09-08 amendment: base advance restarts the complete required attempt + +The attempt-wide base capture prevents mixed-base evidence, but rejection alone +does not provide liveness. If the protected base advances after +`detect-languages` succeeds, `rerun-failed-jobs` cannot rerun that successful +capture job or any successful sibling shard. The unchanged PR head can remain +pinned to the old base without another pull-request event. + +The coordinator now selects one of two validated wake modes. `failed` retains +the exact failed-language map and existing failed-job rerun. `all` is selected +only after a live base advance; it replaces the payload base with that verified +live SHA and carries every terminal success/failure matrix job. The handler +revalidates the open PR/head/base, run path, exact job names and IDs, language +coverage, and absence of unrelated failures before calling the exact run's +whole-workflow rerun endpoint. This restarts the successful capture job and all +matrix shards in one new attempt. Arbitrary mode values, non-terminal jobs, +partial maps, stale metadata, and unrelated failures fail before mutation. + +The handler also closes the later validation-to-wake window. Wake revalidates +the open pull request, unchanged head, and unchanged base ref. A different +well-formed base SHA is accepted only when compare evidence proves the old SHA +is the merge-base ancestor of the new protected-ref SHA; after authenticating +the old attempt's exact run, jobs, receipts, SARIF, and handler provenance, +settlement uses `all` for that exact run. Closed pull +requests, changed heads or base refs, and malformed base identities still fail +before any Actions mutation. A concurrent whole-run wake is accepted only by +the existing exact newer-attempt proof. + +Receipt reuse also requires exactly one Medium+ gate step whose conclusion is +consistent with the published state, in addition to terminal job, successful +SARIF preservation, exact artifact, immutable source, and run provenance. +Missing, duplicate, or contradictory gates are not terminal evidence. Shard, +coordinator, and settlement consumers share this rule so no alternate receipt +reader can bypass it. + +### 2026-09-08 amendment: one verdict set spans both evidence channels + +Status publication is optional because repository-scoped credentials can +forbid it even after a valid scan and SARIF artifact exist. Consequently, +status receipts and direct run evidence are two observations of one producer +set, not ordered fallback authorities. Every consumer enumerates and fully +authenticates both channels, normalizes candidates by exact producer run ID and +state, and then applies one cardinality decision. Zero candidates is pending; +exactly one is a terminal verdict; more than one or conflicting states are +ambiguous and fail closed with exact redaction-safe run-ID/state telemetry. +Ambiguity terminates before OIDC or App-token acquisition and before another +dispatch, because another producer cannot reduce an already contradictory set. + +Keeping the former shell short circuit was rejected: a status from producer A +would suppress inspection of status-less direct producer B. Rejecting all +dual-channel observations was also rejected because the same producer can +legitimately appear in both channels; identical `(run_id, state)` observations +deduplicate to one authenticated candidate. + +### 2026-09-08 amendment: one run-wide wake retains bounded credential fallback + +Settlement previously selected the first nonempty wake credential before its +first GitHub API request. Presence does not prove repository permission, so a +configured but target-denied primary token could shadow a later credential +that had the exact Actions authority required for the same run. + +The selected repair preserves the single non-matrix settlement owner and tries +the bounded Actions credential chain in order: +`PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, and the workflow's native +token only when the target is the handler repository itself. The same helper +performs every live PR/run/job/status/artifact/ancestry read and the final +exact-run POST. The chain does not broaden endpoint, run, head, base, or job +authority; all identities are revalidated as before, and exhaustion is a +terminal failure. The repository-scoped App token used inside a scan matrix +job is deliberately excluded because a secret output cannot be transferred +to the separate wake job. + +Selecting one token eagerly was rejected because it recreated credential +shadowing. Moving wake back into each matrix job was rejected because it +reintroduces the sibling callback race. Passing the scan App token between jobs +was rejected because it would expand credential lifetime and cross a boundary +that GitHub Actions does not provide safely. ## Alternatives considered and rejected @@ -270,6 +487,9 @@ blocker for this one. documented, evidently deliberate platform limitation ("CodeQL requires configuration at the repository level"), not a bug report candidate. +- **Wake each failed language job independently:** rejected after the + 2026-09-08 two-language reproduction; GitHub moves the whole workflow run + back to running after the first job wake and rejects the sibling callback. ## Risks and effects @@ -278,7 +498,7 @@ blocker for this one. requirement on `scripts/ci/`) to the org's central CI surface — more surface area to maintain, offset by removing ~70 lines of duplicated inline Python between `analyze-head`/`analyze-merge` today. - exact run/job wake-up follows the OpenCode runner-release pattern while + exact run/job settlement follows the OpenCode runner-release pattern while avoiding one occupied runner per language for the scan's full duration. - A repository and pull request have one active native handler run. Language parallelism is bounded by the detected CodeQL matrix inside that run, and a @@ -297,7 +517,7 @@ blocker for this one. 1. Implement `scripts/ci/codeql_sarif_gate.py` + its test, extracted from the current inline gate in `codeql-pr.yml`. 2. Implement `codeql-scan-dispatch.yml` per the design above. -3. Rewrite `codeql-pr.yml`'s `analyze-head` job into the dispatch+exact-job-wake shape; +3. Rewrite `codeql-pr.yml`'s `analyze-head` job into the dispatch+exact-run-settlement shape; delete `analyze-merge` (tracked as future work, not silently lost — this ADR is the record). 4. Add a permanent contract test asserting no `codeql-action` reference 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..1a1da6419e --- /dev/null +++ b/docs/doctoring/codeql-live-base-terminal-boundary.md @@ -0,0 +1,143 @@ +# 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`와 중앙 handler workflow source SHA `S`도 분리한다. `A`는 +target review base에 결과를 결속하고, `S`는 required workflow가 dispatch를 만든 +시점의 immutable `github.workflow_sha`다. Producer는 `S`를 payload에 싣고 handler +title과 terminal receipt에 함께 결속한다. `repository_dispatch` receiver는 default +branch에서 실행되므로 runtime source `T`가 이후 전진할 수 있다. Handler와 모든 +direct-evidence consumer는 `S == T`이거나 GitHub compare가 `S`를 `T`의 exact merge +base로 확인하고 `T`가 ahead이면서 behind가 아님을 증명할 때만 수용한다. 따라서 +target base와 central source가 서로 달라도 유효하고, 호환되는 protected-main 전진 +뒤에도 기존 immutable producer `S`를 보존한다. Diverged/reversed/missing/malformed +또는 조회할 수 없는 source 관계는 fail closed한다. 실제 target run `34186647327`의 +`referenced_workflows=[]`는 source 부재를 뜻하지 않으므로 이 optional field나 현재 +`main` tip을 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한다. + +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-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/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 1cc9e20313..c2bf4cff08 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,56 @@ +## 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` 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..fce32bf421 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,7 +57,8 @@ 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 @@ -87,28 +90,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 +101,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 +159,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 +208,101 @@ 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/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 +312,68 @@ 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_PREDECESSOR_RUN_JSON": json.dumps(incomplete_predecessor), + "FAKE_PREDECESSOR_JOBS_JSON": json.dumps( + [predecessor_jobs or {"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_DISPATCH_JOBS_JSON": json.dumps( - dispatch_jobs - if isinstance(dispatch_jobs, list) - else [dispatch_jobs if dispatch_jobs is not None else {"jobs": []}] + "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 +382,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 +507,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 +518,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,200 +530,798 @@ 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 PR can keep its head SHA while the base moves. The native handler already - binds receipts to the live base SHA; the required shard must not accept a - completed dispatch whose run-name still names the predecessor base. - """ - stale_title = _dispatch_scan_title(base_sha="c" * 40) - dispatch_result, _verdict_result = _run_verdict_read( + """A handler on newer protected main can serve an immutable older producer.""" + 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_SOURCE_COMPARE_JSON": json.dumps( { - "name": "CodeQL dispatch scan (python)", - "conclusion": "success", + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "c" * 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_reads_direct_evidence_on_later_job_and_artifact_pages( tmp_path: Path, ) -> None: - """A same-PR/head/language scan for another required run cannot wake this shard. - - Language plus repository/PR/head is not enough: each waiting required job - lives in one required-workflow run. Binding required_run_id in the - dispatch run-name, together with the language job name, is the job - identity the shard can observe without reading client_payload. - """ - other_run_title = _dispatch_scan_title(required_run_id="99") - dispatch_result, _verdict_result = _run_verdict_read( - tmp_path, - statuses=[], - dispatch_runs={ - "workflow_runs": [_completed_dispatch_run(title=other_run_title)] + """Direct evidence must not stop at the first jobs or artifacts page.""" + producer_jobs = [ + { + "jobs": [ + { + "name": "validate-dispatch", + "status": "completed", + "conclusion": "success", + } + ] }, - dispatch_jobs={ + { "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", + }, + ], } ] }, - ) - - 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 + ] + 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, + ) -def test_codeql_pr_fallback_binds_live_base_and_required_run_identity() -> None: - """The required shard looks up the public dispatch run by immutable identity.""" - workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - shard = workflow.split(" analyze-head:\n", 1)[1].split( - " dispatch-current-head:\n", 1 - )[0] + assert 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 - assert "REQUIRED_RUN_ID: ${{ github.run_id }}" in shard - assert 'live_base="$(printf' in shard - assert ( - 'expected_title="CodeQL Scan Dispatch ${TARGET_REPOSITORY}#${PR_NUMBER}' - '@${PR_HEAD_SHA}/${live_base}/${REQUIRED_RUN_ID}"' - ) in shard - assert "Could not validate live pull request base SHA before CodeQL verdict read." in shard +def test_codeql_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 -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, - ) + dispatch_result, verdict_result = _run_verdict_read( + tmp_path, + statuses=[], + producer_run=complete, + producer_runs=[incomplete, complete], ) - 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 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 - assert "while :; do" not in shard - assert "poll_interval_seconds" not in shard - assert "sleep " not in shard - assert "job.check_run_id" not in shard - assert "required_job_id:$required_job_id" not in shard - assert "required_language:$required_language" not in shard - assert "The dispatch workflow will rerun this exact failed CodeQL job" in shard - assert "commits/${PR_HEAD_SHA}/statuses" in shard - assert "repos/ContextualWisdomLab/.github/dispatches" not in shard +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"}, + ], + }, + ] + } -def test_codeql_required_workflow_does_not_gain_actions_write() -> None: - workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - permissions = workflow.split("permissions:\n", 1)[1].split("\njobs:\n", 1)[0] - shard_permissions = workflow.split(" analyze-head:\n", 1)[1].split( - " strategy:\n", 1 - )[0] - coordinator_permissions = workflow.split(" dispatch-current-head:\n", 1)[1].split( - " steps:\n", 1 - )[0] + 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, + ) - assert "actions: write" not in permissions - assert "actions: write" not in shard_permissions - assert "actions: write" not in coordinator_permissions + assert dispatch_result.returncode == 1 + assert verdict_result.returncode == 1 -def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( +def test_codeql_pr_rejects_two_evidence_complete_status_receipts( tmp_path: Path, ) -> None: - """Attempt 1 with no authenticated status releases the runner and does not POST.""" - 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" - + """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"), + ("head_sha", "d" * 40), + ("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 exact producer-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 + assert "sleep " not in shard + assert "job.check_run_id" not in shard + assert "required_job_id:$required_job_id" not in shard + assert "required_language:$required_language" not in shard + assert "The dispatch workflow will rerun this exact failed CodeQL job" in shard + assert "commits/${PR_HEAD_SHA}/statuses" in shard + assert "repos/ContextualWisdomLab/.github/dispatches" not in shard + + +def test_codeql_required_workflow_does_not_gain_actions_write() -> None: + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + permissions = workflow.split("permissions:\n", 1)[1].split("\njobs:\n", 1)[0] + shard_permissions = workflow.split(" analyze-head:\n", 1)[1].split( + " strategy:\n", 1 + )[0] + coordinator_permissions = workflow.split(" dispatch-current-head:\n", 1)[1].split( + " steps:\n", 1 + )[0] + + assert "actions: write" not in permissions + assert "actions: write" not in shard_permissions + assert "actions: write" not in coordinator_permissions + + +def test_codeql_pr_attempt_one_without_verdict_fails_pending_without_dispatch( + tmp_path: Path, +) -> None: + """Attempt 1 with no authenticated status releases the runner and does not POST.""" + 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" + workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") dispatch_script = _extract_run_block(workflow_text, DISPATCH_STEP_NAME) verdict_script = _extract_run_block(workflow_text, VERDICT_STEP_NAME) @@ -539,14 +1338,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 +1351,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 +1405,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 +1443,16 @@ 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" + " */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 +1484,13 @@ 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, + 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 +1502,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 +1525,38 @@ def _run_coordinator( ], } statuses = statuses if statuses is not None else [] - fake_bin, post_log, post_body = _write_coordinator_fakes( - tmp_path, pull=pull, jobs=jobs, statuses=statuses + handler_source_sha = handler_source_sha or "c" * 40 + 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, + 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 +1566,29 @@ 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_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 +1601,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 +1623,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,37 +1676,589 @@ 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 client["rerun_request"]["mode"] == "failed" assert "required_job_id" not in client assert "required_language" not in client languages = [entry["language"] for entry in client["matrix"]] assert languages == ["python", "actions"] jobs_by_language = { - entry["language"]: entry["job_id"] for entry in client["required_jobs"] + entry["language"]: entry["job_id"] for entry in client["rerun_request"]["required_jobs"] } assert jobs_by_language == {"python": 101, "actions": 102} +def test_codeql_coordinator_dispatches_against_shared_attempt_base( + tmp_path: Path, +) -> None: + """Coordinator uses the same upstream-captured base as every shard.""" + live_base_sha = "d" * 40 + 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": 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": 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["rerun_request"]["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() + + +def test_codeql_coordinator_accepts_descendant_handler_source( + tmp_path: Path, +) -> None: + """Coordinator accepts direct evidence from compatible newer handler main.""" + 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, + source_compare={ + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "c" * 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_selects_complete_duplicate_title_successor( + tmp_path: Path, +) -> None: + """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": 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["rerun_request"]["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 "failed jobs outside the exact language map" in result.stdout + assert not post_log.exists() + + def test_codeql_coordinator_skips_dispatch_when_every_language_has_a_verdict( tmp_path: Path, ) -> None: """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, statuses=[ { - "context": "codeql-dispatch/python", + "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": "codeql-dispatch/actions", + "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 @@ -824,32 +2291,6 @@ def test_codeql_coordinator_fails_closed_when_a_shard_job_id_is_missing( assert not post_log.exists() -def test_codeql_coordinator_dispatches_the_live_base_after_a_same_head_retarget( - tmp_path: Path, -) -> None: - """A retargeted PR must dispatch against the live base, not the event snapshot.""" - live_base = "c" * 40 - result, post_log, post_body = _run_coordinator( - tmp_path, - pull={ - "state": "open", - "head": {"sha": "b" * 40, "ref": "feature"}, - "base": {"sha": live_base, "ref": "release"}, - }, - env_overrides={"PR_BASE_SHA": "a" * 40, "PR_BASE_REF": "main"}, - ) - - assert result.returncode == 0, result.stderr + result.stdout - assert post_log.read_text(encoding="utf-8").splitlines() == [ - "repos/ContextualWisdomLab/.github/dispatches" - ] - client = json.loads(post_body.read_text(encoding="utf-8"))["client_payload"] - assert client["pr_base_sha"] == live_base - assert client["pr_base_ref"] == "release" - assert client["pr_head_sha"] == "b" * 40 - assert client["required_run_id"] == "99" - - def test_codeql_coordinator_does_not_dispatch_a_closed_or_stale_pull_request( tmp_path: Path, ) -> None: diff --git a/tests/test_codeql_scan_dispatch_workflow_contract.py b/tests/test_codeql_scan_dispatch_workflow_contract.py index dd30c8506d..4a62728c5f 100644 --- a/tests/test_codeql_scan_dispatch_workflow_contract.py +++ b/tests/test_codeql_scan_dispatch_workflow_contract.py @@ -17,12 +17,222 @@ 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 ( workflow_level_cancels_in_progress, workflow_level_concurrency_group, + workflow_step, +) + + +@pytest.mark.parametrize( + ("gate", "upload", "expected_state"), + [ + ("success", "failure", None), + ("success", "skipped", None), + ("success", "", None), + ("success", "cancelled", None), + ("success", "success", "success"), + ("failure", "success", "failure"), + ("skipped", "success", "error"), + ], ) +def test_terminal_publication_requires_preserved_sarif( + tmp_path: Path, gate: str, upload: str, expected_state: str | None +) -> None: + """Execute production publication shell; missing artifacts cannot wake jobs.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + script = _extract_run_block(workflow, "Publish CodeQL dispatch status") + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + post_log = tmp_path / "status-posts" + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\nset -euo pipefail\n" + 'test "$1" = api && test "$2" = -X && test "$3" = POST\n' + 'test "$4" = "repos/ContextualWisdomLab/naruon/statuses/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\n' + 'test "$5" = -f\n' + 'printf "%s\\n" "$6" >>"$FAKE_POST_LOG"\n' + "printf '%s\\n' '{\"creator\":{\"login\":\"opencode-agent[bot]\"}}'\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + result = subprocess.run( + [shutil.which("bash") or "bash"], input=script, text=True, + capture_output=True, check=False, timeout=30, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_POST_LOG": str(post_log), + "GATE_OUTCOME": gate, "SARIF_UPLOAD_OUTCOME": upload, + "TARGET_APP_STATUS_TOKEN": "fixture-token", + "PR_REVIEW_MERGE_STATUS_TOKEN": "", + "OPENCODE_APPROVE_STATUS_TOKEN": "", "GITHUB_STATUS_READ_TOKEN": "", + "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "BASE_SHA": "a" * 40, "HEAD_SHA": "b" * 40, "LANGUAGE": "python", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", "GITHUB_RUN_ID": "99", + "REQUIRED_RUN_ID": "42", + "PRODUCER_SOURCE_SHA": "c" * 40, + }, + ) + # Settlement is a separate non-matrix job and independently authenticates + # either a receipt or exact scan-plus-artifact evidence. + wake = workflow_step(workflow, "Settle exact CodeQL required run") + assert wake.split(" env:", 1)[0] == ( + " - name: Settle exact CodeQL required run\n" + " if: >-\n" + " always()\n" + " && needs.validate-dispatch.outputs.target_repository != ''\n" + " && needs.validate-dispatch.outputs.pr_number != ''\n" + " && needs.validate-dispatch.outputs.head_sha != ''\n" + " && needs.validate-dispatch.outputs.required_run_id != ''\n" + " && needs.validate-dispatch.outputs.required_jobs != ''\n" + ) + if expected_state is None: + assert not post_log.exists(), result.stdout + assert result.returncode == 1 + assert "SARIF evidence was not preserved" in result.stdout + else: + assert result.returncode == 0, result.stderr + assert post_log.read_text(encoding="utf-8").splitlines() == [f"state={expected_state}"] + + +def test_terminal_publication_binds_actual_upload_step_outcome() -> None: + """The tested shell input must come from the existing artifact action.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + upload = workflow_step(workflow, "Preserve CodeQL SARIF evidence") + assert upload.split(" uses:", 1)[0] == ( + " - name: Preserve CodeQL SARIF evidence\n" + " id: sarif_upload\n" + " if: always() && hashFiles('codeql-results-dispatch/**/*.sarif') != ''\n" + ) + assert " uses: actions/upload-artifact@" in upload + assert " if-no-files-found: error" in upload.splitlines() + publish = workflow_step(workflow, "Publish CodeQL dispatch status") + env = publish.split(" env:\n", 1)[1].split(" run:", 1)[0] + binding = [line for line in env.splitlines() if "SARIF_UPLOAD_OUTCOME" in line] + assert binding == [" SARIF_UPLOAD_OUTCOME: ${{ steps.sarif_upload.outcome }}"] + + +def test_self_repository_app_403_falls_back_to_the_exact_workflow_token( + tmp_path: Path, +) -> None: + """Reproduce the live App 403 and prove the fallback publisher is explicit.""" + script = _extract_run_block( + WORKFLOW_PATH.read_text(encoding="utf-8"), "Publish CodeQL dispatch status" + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + call_log = tmp_path / "calls" + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\nset -euo pipefail\n" + 'printf "%s\\n" "$GH_TOKEN" >>"$FAKE_CALL_LOG"\n' + 'if [ "$GH_TOKEN" = app-token ]; then\n' + ' echo "gh: Resource not accessible by integration (HTTP 403)" >&2\n' + " exit 1\n" + "fi\n" + 'test "$GH_TOKEN" = github-token\n' + 'test "$1" = api && test "$2" = -X && test "$3" = POST\n' + 'test "$4" = "repos/ContextualWisdomLab/.github/statuses/${HEAD_SHA}"\n' + "printf '%s\\n' '{\"creator\":{\"login\":\"github-actions[bot]\"}}'\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + result = subprocess.run( + [shutil.which("bash") or "bash"], input=script, text=True, + capture_output=True, check=False, timeout=30, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_CALL_LOG": str(call_log), + "GATE_OUTCOME": "success", "SARIF_UPLOAD_OUTCOME": "success", + "TARGET_APP_STATUS_TOKEN": "app-token", + "PR_REVIEW_MERGE_STATUS_TOKEN": "", + "OPENCODE_APPROVE_STATUS_TOKEN": "", + "GITHUB_STATUS_READ_TOKEN": "github-token", + "TARGET_REPOSITORY": "ContextualWisdomLab/.github", + "BASE_SHA": "a" * 40, "HEAD_SHA": "b" * 40, + "LANGUAGE": "python", "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "GITHUB_RUN_ID": "123", + "REQUIRED_RUN_ID": "42", + "PRODUCER_SOURCE_SHA": "c" * 40, + }, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert call_log.read_text(encoding="utf-8").splitlines() == [ + "app-token", "github-token", + ] + assert "Resource not accessible by integration (HTTP 403)" in result.stdout + assert "using github-token" in result.stdout + + +def test_status_post_with_unexpected_creator_falls_through_to_trusted_publisher( + tmp_path: Path, +) -> None: + """HTTP success is not publication until the response creator is trusted.""" + script = _extract_run_block( + WORKFLOW_PATH.read_text(encoding="utf-8"), "Publish CodeQL dispatch status" + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + call_log = tmp_path / "calls" + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\nset -euo pipefail\n" + 'printf "%s\n" "$GH_TOKEN" >>"$FAKE_CALL_LOG"\n' + 'test "$1" = api && test "$2" = -X && test "$3" = POST\n' + 'if [ "$GH_TOKEN" = app-token ]; then\n' + ' printf "%s\n" \'{"creator":{"login":"unexpected-user"}}\'\n' + " exit 0\n" + "fi\n" + 'test "$GH_TOKEN" = github-token\n' + 'printf "%s\n" \'{"creator":{"login":"github-actions[bot]"}}\'\n', + encoding="utf-8", + ) + fake_gh.chmod(0o755) + result = subprocess.run( + [shutil.which("bash") or "bash"], + input=script, + text=True, + capture_output=True, + check=False, + timeout=30, + env={ + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_CALL_LOG": str(call_log), + "GATE_OUTCOME": "success", + "SARIF_UPLOAD_OUTCOME": "success", + "TARGET_APP_STATUS_TOKEN": "app-token", + "PR_REVIEW_MERGE_STATUS_TOKEN": "", + "OPENCODE_APPROVE_STATUS_TOKEN": "", + "GITHUB_STATUS_READ_TOKEN": "github-token", + "TARGET_REPOSITORY": "ContextualWisdomLab/.github", + "BASE_SHA": "a" * 40, + "HEAD_SHA": "b" * 40, + "LANGUAGE": "python", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "ContextualWisdomLab/.github", + "GITHUB_RUN_ID": "123", + "REQUIRED_RUN_ID": "42", + "PRODUCER_SOURCE_SHA": "c" * 40, + }, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert call_log.read_text(encoding="utf-8").splitlines() == [ + "app-token", + "github-token", + ] + assert "unexpected creator" in result.stdout + assert "using github-token" in result.stdout REPO_ROOT = Path(__file__).resolve().parents[1] WORKFLOW_PATH = REPO_ROOT / ".github/workflows/codeql-scan-dispatch.yml" @@ -36,7 +246,7 @@ "Fetch the pinned CodeQL SARIF gate script", "Materialize pull request head for CodeQL scan", "Publish CodeQL dispatch status", - "Wake exact CodeQL required job", + "Settle exact CodeQL required run", ) @@ -78,7 +288,7 @@ 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 'context="codeql-dispatch/${LANGUAGE}/${BASE_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 @@ -94,6 +304,23 @@ def test_codeql_scan_dispatch_workflow_structure(): assert "pull_request_target:" not in workflow +def test_codeql_scan_dispatch_publishes_base_bound_workflow_receipt() -> None: + """Terminal status carries the base, head, language, and producer identity.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + + assert "BASE_SHA: ${{ needs.validate-dispatch.outputs.base_sha }}" in workflow + assert 'context="codeql-dispatch/${LANGUAGE}/${BASE_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 '-f description="$receipt_description"' in workflow + assert ( + '-f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/' + '${GITHUB_RUN_ID}"' in workflow + ) + + def test_codeql_scan_dispatch_keeps_current_head_language_shards_independent(): """Sibling languages stay independent as jobs in one run, not as separate runs. @@ -137,7 +364,10 @@ 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', + 'case "$2" in\n' + ' repos/ContextualWisdomLab/.github/compare/*) printf \'%s\\n\' "$FAKE_SOURCE_COMPARE_JSON" ;;\n' + ' *) printf \'%s\\n\' "$FAKE_PULL_JSON" ;;\n' + 'esac\n', encoding="utf-8", ) fake_gh.chmod(0o755) @@ -160,6 +390,16 @@ def _run_validate_step(tmp_path: Path, env_overrides: dict[str, str], pull_reque "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": "failed", + "SUPPLIED_PRODUCER_SOURCE_SHA": "c" * 40, + "WORKFLOW_SOURCE_SHA": "c" * 40, + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + { + "status": "identical", + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "c" * 40}, + } + ), "SUPPLIED_REQUIRED_JOB_ID": "", "SUPPLIED_REQUIRED_LANGUAGE": "", **env_overrides, @@ -189,11 +429,28 @@ 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 "rerun_mode=failed" 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 +@pytest.mark.parametrize("rerun_mode", ["", "failure", "ALL", "all-jobs"]) +def test_codeql_scan_dispatch_validate_step_rejects_invalid_rerun_mode( + tmp_path: Path, rerun_mode: str, +) -> None: + """Only the bounded failed-job and whole-attempt wake modes are accepted.""" + result = _run_validate_step( + tmp_path, + {"SUPPLIED_RERUN_MODE": rerun_mode}, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "rerun mode" in result.stdout.lower() + + 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()) @@ -326,7 +583,7 @@ def test_codeql_scan_dispatch_validate_step_rejects_malformed_matrix(tmp_path): assert "at least one valid language/build-mode shard" in missing_build_mode.stdout assert "at least one valid language/build-mode shard" in empty_matrix.stdout assert "at least one valid language/build-mode shard" in invalid_language.stdout - assert "does not match the dispatched languages one-to-one" in mismatched_jobs.stdout + assert "is duplicate or does not cover every dispatched language" in mismatched_jobs.stdout def test_codeql_scan_dispatch_validate_step_accepts_multi_language_payload(tmp_path): @@ -357,6 +614,109 @@ 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_accepts_pending_subset_with_complete_failed_job_map( + tmp_path, +): + """Pending scan languages may be a subset of run-wide failed-job identity.""" + 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": 55}, + ] + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + compact = result.output_path.read_text(encoding="utf-8").replace(" ", "") + assert '"language":"python"' in compact + assert '"job_id":43' in compact + assert '"language":"actions"' in compact + assert '"job_id":55' in compact + + +@pytest.mark.parametrize( + ("supplied", "runtime"), + [("", "c" * 40), ("not-a-sha", "c" * 40), ("c" * 40, "d" * 40)], +) +def test_codeql_scan_dispatch_rejects_missing_or_wrong_producer_source( + tmp_path: Path, supplied: str, runtime: str, +) -> None: + """Payload source must equal the immutable handler workflow source.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_PRODUCER_SOURCE_SHA": supplied, + "WORKFLOW_SOURCE_SHA": runtime, + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "producer source" in result.stdout.lower() + + +def test_codeql_scan_dispatch_accepts_ancestor_producer_source( + tmp_path: Path, +) -> None: + """A protected producer source remains compatible after handler main advances.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_PRODUCER_SOURCE_SHA": "c" * 40, + "WORKFLOW_SOURCE_SHA": "d" * 40, + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + { + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "c" * 40}, + } + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert "producer_source_sha=" + "c" * 40 in result.output_path.read_text( + encoding="utf-8" + ) + + +def test_codeql_scan_dispatch_rejects_divergent_producer_source( + tmp_path: Path, +) -> None: + """A source outside the immutable handler ancestry fails closed.""" + result = _run_validate_step( + tmp_path, + { + "SUPPLIED_PRODUCER_SOURCE_SHA": "c" * 40, + "WORKFLOW_SOURCE_SHA": "d" * 40, + "FAKE_SOURCE_COMPARE_JSON": json.dumps( + { + "status": "diverged", + "ahead_by": 1, + "behind_by": 1, + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "e" * 40}, + } + ), + }, + _matching_pull_request(), + ) + + assert result.returncode == 1 + assert "producer source" in result.stdout.lower() + + 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. @@ -463,10 +823,12 @@ def test_codeql_scan_dispatch_validate_step_rejects_unusable_legacy_payload(tmp_ assert language_mismatch.returncode == 1 assert multi_language_legacy.returncode == 1 assert invalid_job_id.returncode == 1 - assert "does not match the dispatched languages one-to-one" in missing_both.stdout - assert "does not match the dispatched languages one-to-one" in language_mismatch.stdout - assert "does not match the dispatched languages one-to-one" in multi_language_legacy.stdout - assert "does not match the dispatched languages one-to-one" in invalid_job_id.stdout + assert "is duplicate or does not cover every dispatched language" in missing_both.stdout + assert "is duplicate or does not cover every dispatched language" in language_mismatch.stdout + assert "is duplicate or does not cover every dispatched language" in multi_language_legacy.stdout + assert "is duplicate or does not cover every dispatched language" in invalid_job_id.stdout + + def test_codeql_scan_dispatch_validate_step_rejects_stale_head_sha(tmp_path): @@ -506,77 +868,58 @@ def test_codeql_scan_dispatch_is_not_in_the_required_workflow_ruleset_scope(): def test_codeql_scan_dispatch_run_name_binds_base_and_required_run() -> None: - """Public run identity includes base SHA and required run id without changing concurrency. - - The required shard cannot read client_payload. Encoding those fields in - run-name lets it reject a same-head retarget or a different waiting - required run. The #2008/#2009 group stays repository+PR so a newer HEAD - of the same pull request still cancels its predecessor. - """ + """Native run identity cannot be shared across base or required-run contexts.""" workflow = WORKFLOW_PATH.read_text(encoding="utf-8") header = workflow.split("\non:", 1)[0] - group_value = workflow_level_concurrency_group(workflow) - assert "github.event.client_payload.pr_head_sha" in header assert "github.event.client_payload.pr_base_sha" in header assert "github.event.client_payload.required_run_id" in header - assert "github.event.client_payload.pr_base_sha" not in group_value - assert "github.event.client_payload.required_run_id" not in group_value - assert "github.event.client_payload.target_repository" in group_value - assert "github.event.client_payload.pr_number" in group_value - - -def test_dispatch_publish_keeps_successful_scan_when_status_write_is_denied() -> None: - """A clean SARIF gate must not fail the handler solely because POST /statuses 403s. - - opencode-agent is installed with statuses:read. Cross-repo github.token cannot - write naruon commit statuses. The completed scan job is the remaining evidence. - """ - workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - publish = workflow.split(" - name: Publish CodeQL dispatch status\n", 1)[1].split( - "\n - name: Wake exact CodeQL required job\n", 1 - )[0] - assert "GATE_OUTCOME" in publish - assert 'if [ "$GATE_OUTCOME" = "success" ]; then' in publish - assert "completed dispatch scan job remains the evidence" in publish - assert "continue-on-error:" not in publish - assert "cancel-in-progress: true" not in publish - -def test_dispatch_wakes_only_the_exact_failed_codeql_job() -> None: +def test_dispatch_settles_only_the_exact_failed_codeql_run() -> None: workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - wake = workflow.split(" - name: Wake exact CodeQL required job\n", 1)[1].split( + wake = workflow.split(" - name: Settle exact CodeQL required run\n", 1)[1].split( "\n\n - name:", 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 "steps.publish_status.outcome" not in wake + assert 'github_api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}"' in wake + assert 'github_api "repos/${TARGET_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}"' in wake + assert 'github_api "repos/${TARGET_REPOSITORY}/actions/jobs/${required_job_id}"' in wake + assert "commits/${HEAD_SHA}/statuses?per_page=100" 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 'wake_endpoint="rerun-failed-jobs"' in wake + assert 'actions/runs/${REQUIRED_RUN_ID}/${wake_endpoint}' in wake + assert 'actions/jobs/${REQUIRED_JOB_ID}/rerun' not in wake assert "sleep " not in wake def test_dispatch_wake_has_only_trusted_actions_write_boundary() -> None: workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - scan = workflow.split(" scan:\n", 1)[1] + scan = workflow.split(" scan:\n", 1)[1].split(" wake-required:\n", 1)[0] scan_permissions = scan.split(" strategy:\n", 1)[0] - - assert "actions: write" in scan_permissions + wake = workflow.split(" wake-required:\n", 1)[1] + + assert "actions: write" not in scan_permissions + assert "actions: read" in scan_permissions + assert "needs: [validate-dispatch, scan]" in wake + assert "actions: write" in wake.split(" steps:\n", 1)[0] + assert "matrix:" not in wake.split(" steps:\n", 1)[0] + assert "steps.publish_status.outcome" not in wake 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 wake + assert "needs.validate-dispatch.outputs.required_jobs != ''" in wake assert "github.event.client_payload.required_job_id" not in scan + assert "PR_REVIEW_MERGE_WAKE_TOKEN" in wake + assert "OPENCODE_APPROVE_WAKE_TOKEN" in wake + assert "GITHUB_WAKE_TOKEN" in wake + assert "WAKE_TOKEN_SOURCE" not in wake def _run_wake_step( @@ -584,15 +927,39 @@ def _run_wake_step( *, pull: dict | None = None, run: dict | None = None, - job: dict | None = None, + jobs: list[dict] | None = None, + statuses: list[dict] | None = None, + post_failure: bool = False, + settled_jobs: list[dict] | None = None, + target_repository: str = "ContextualWisdomLab/naruon", + producer_jobs: dict | list[dict] | None = None, + producer_artifacts: dict | list[dict] | None = None, + predecessor_run: dict | None = None, + predecessor_jobs: dict | list[dict] | None = None, + predecessor_artifacts: dict | list[dict] | None = None, + handler_source_sha: str | None = None, + source_compare: dict | None = None, + base_compare: dict | None = None, + rerun_mode: str = "failed", + env_overrides: dict[str, str] | None = None, ) -> tuple[subprocess.CompletedProcess[str], Path]: - """Execute the exact wake block against fixture-backed GitHub API responses.""" + """Execute exact-run settlement against fixture-backed GitHub 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}} + base_sha = "a" * 40 + handler_source_sha = handler_source_sha or "c" * 40 + pull = pull or { + "state": "open", + "head": {"sha": head_sha}, + "base": { + "repo": {"full_name": target_repository}, + "ref": "main", + "sha": base_sha, + }, + } run = run or { "id": 42, "event": "pull_request", @@ -601,16 +968,91 @@ 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", + jobs = jobs or [ + { + "id": 43, "run_id": 42, "run_attempt": 1, "head_sha": head_sha, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", "conclusion": "failure", + }, + { + "id": 44, "run_id": 42, "run_attempt": 1, "head_sha": head_sha, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", "conclusion": "failure", + }, + ] + statuses = statuses if statuses is not None else [ + { + "context": f"codeql-dispatch/python/{base_sha}", + "description": ( + f"cwl1;h={head_sha};w=codeql-scan-dispatch;r=42;" + f"s={'c' * 40}" + ), + "target_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/100", + "state": "success", "creator": {"login": "opencode-agent[bot]"}, + }, + { + "context": f"codeql-dispatch/actions/{base_sha}", + "description": ( + f"cwl1;h={head_sha};w=codeql-scan-dispatch;r=42;" + f"s={'c' * 40}" + ), + "target_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/100", + "state": "success", "creator": {"login": "opencode-agent[bot]"}, + }, + ] + settled_jobs = settled_jobs if settled_jobs is not None else jobs + producer_run = { + "id": 100, + "event": "repository_dispatch", + "path": ".github/workflows/codeql-scan-dispatch.yml", + "head_branch": "main", + "head_sha": handler_source_sha, + "display_title": ( + f"CodeQL Scan Dispatch {target_repository}#42@{head_sha}/{base_sha}/42/" + f"{'c' * 40}" + ), + "repository": {"full_name": "ContextualWisdomLab/.github"}, + "actor": {"login": "opencode-agent[bot]"}, + "triggering_actor": {"login": "opencode-agent[bot]"}, + } + predecessor_run = predecessor_run or { + **producer_run, + "id": 99, + } + predecessor_jobs = predecessor_jobs if predecessor_jobs is not None else { + "jobs": [] + } + predecessor_artifacts = ( + predecessor_artifacts if predecessor_artifacts is not None + else {"artifacts": []} + ) + producer_jobs = producer_jobs if producer_jobs is not None else { + "jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + *[ + { + "name": f"CodeQL dispatch scan ({language})", + "status": "completed", + "conclusion": "failure", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + {"name": "Publish CodeQL dispatch status", "conclusion": "failure"}, + ], + } + for language in ("python", "actions") + ], + ] + } + producer_artifacts = producer_artifacts if producer_artifacts is not None else { + "artifacts": [ + {"name": f"codeql-dispatch-{language}-100-1", "expired": False} + for language in ("python", "actions") + ] } 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) @@ -623,14 +1065,32 @@ def _run_wake_step( 'if [ "${2:-}" = "-X" ]; then\n' ' test "$3" = POST\n' ' printf \'%s\\n\' "$4" >>"$FAKE_POST_LOG"\n' + ' if [ -n "${FAKE_DENIED_TOKEN:-}" ] && [ "${GH_TOKEN:-}" = "$FAKE_DENIED_TOKEN" ]; then printf \'%s\\n\' "gh: forbidden (HTTP 403)" >&2; exit 1; fi\n' + ' if [ "$FAKE_POST_FAILURE" = 1 ]; then printf \'%s\\n\' "gh: workflow run already running (HTTP 403)" >&2; exit 1; fi\n' " exit 0\n" "fi\n" - 'case "$2" in\n' + 'if [ "${2:-}" = "--paginate" ] && [ "${3:-}" = "--slurp" ]; then\n' + ' case "${4:-}" in\n' + ' */statuses*) printf \'%s\\n\' "$FAKE_STATUSES_JSON" ;;\n' + ' */actions/runs/100/jobs*) printf \'%s\\n\' "$FAKE_PRODUCER_JOBS_JSON" ;;\n' + ' */actions/runs/100/artifacts*) printf \'%s\\n\' "$FAKE_PRODUCER_ARTIFACTS_JSON" ;;\n' + ' */actions/runs/99/jobs*) printf \'%s\\n\' "$FAKE_PREDECESSOR_JOBS_JSON" ;;\n' + ' */actions/runs/99/artifacts*) printf \'%s\\n\' "$FAKE_PREDECESSOR_ARTIFACTS_JSON" ;;\n' + ' *) exit 1 ;;\n' + ' esac\n' + 'elif [ "${2:-}" = "--paginate" ]; then\n' + ' if [[ "${3:-}" == *"filter=all"* ]]; then body=$FAKE_ALL_JOBS_JSON; else body=$FAKE_LATEST_JOBS_JSON; fi\n' + ' printf \'%s\\n\' "$body" | jq -c \'.jobs[]\'\n' + 'else case "$2" in\n' ' */pulls/*) printf \'%s\\n\' "$FAKE_PULL_JSON" ;;\n' + ' */compare/*) if [[ "$2" == "repos/${TARGET_REPOSITORY}/compare/${BASE_SHA}..."* ]]; then printf \'%s\\n\' "$FAKE_BASE_COMPARE_JSON"; else printf \'%s\\n\' "$FAKE_SOURCE_COMPARE_JSON"; fi ;;\n' + ' repos/ContextualWisdomLab/.github/actions/runs/100) printf \'%s\\n\' "$FAKE_PRODUCER_RUN_JSON" ;;\n' + ' repos/ContextualWisdomLab/.github/actions/runs/99) printf \'%s\\n\' "$FAKE_PREDECESSOR_RUN_JSON" ;;\n' ' */actions/runs/*) printf \'%s\\n\' "$FAKE_RUN_JSON" ;;\n' - ' */actions/jobs/*) printf \'%s\\n\' "$FAKE_JOB_JSON" ;;\n' + ' */actions/jobs/43) printf \'%s\\n\' "$FAKE_JOB_43_JSON" ;;\n' + ' */actions/jobs/44) printf \'%s\\n\' "$FAKE_JOB_44_JSON" ;;\n' " *) exit 1 ;;\n" - "esac\n", + "esac; fi\n", encoding="utf-8", ) fake_gh.chmod(0o755) @@ -639,13 +1099,57 @@ 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_PRODUCER_RUN_JSON": json.dumps(producer_run), + "FAKE_PREDECESSOR_RUN_JSON": json.dumps(predecessor_run), + "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_PREDECESSOR_JOBS_JSON": json.dumps( + predecessor_jobs if isinstance(predecessor_jobs, list) + else [predecessor_jobs] + ), + "FAKE_PREDECESSOR_ARTIFACTS_JSON": json.dumps( + predecessor_artifacts if isinstance(predecessor_artifacts, list) + else [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_BASE_COMPARE_JSON": json.dumps( + base_compare + or { + "status": "identical", + "ahead_by": 0, + "behind_by": 0, + "base_commit": {"sha": base_sha}, + "merge_base_commit": {"sha": base_sha}, + } + ), + "FAKE_JOB_43_JSON": json.dumps(next(job for job in jobs if job["id"] == 43)), + "FAKE_JOB_44_JSON": json.dumps(next(job for job in jobs if job["id"] == 44)), + "FAKE_STATUSES_JSON": json.dumps([statuses]), + "FAKE_LATEST_JOBS_JSON": json.dumps({"jobs": jobs}), + "FAKE_ALL_JOBS_JSON": json.dumps({"jobs": settled_jobs}), + "FAKE_POST_FAILURE": "1" if post_failure else "0", + "FAKE_DENIED_TOKEN": "", "FAKE_POST_LOG": str(post_log), - "GH_TOKEN": "fake-token", - "WAKE_TOKEN_SOURCE": "PR_REVIEW_MERGE_TOKEN", - "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "PR_REVIEW_MERGE_WAKE_TOKEN": "fake-token", + "OPENCODE_APPROVE_WAKE_TOKEN": "", + "GITHUB_WAKE_TOKEN": "", + "TARGET_REPOSITORY": target_repository, "PR_NUMBER": "42", "HEAD_SHA": head_sha, + "BASE_REF": "main", + "BASE_SHA": base_sha, "REQUIRED_RUN_ID": "42", "REQUIRED_JOBS": json.dumps( [ @@ -653,29 +1157,332 @@ def _run_wake_step( {"language": "actions", "job_id": 44}, ] ), - "REQUIRED_LANGUAGE": "python", + "RERUN_MODE": rerun_mode, + "PRODUCER_RUN_ID": "100", + "PRODUCER_SOURCE_SHA": "c" * 40, + "HANDLER_REPOSITORY": "ContextualWisdomLab/.github", } + if env_overrides: + env.update(env_overrides) 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: +def test_dispatch_settlement_reruns_failed_jobs_only_after_all_receipts( + tmp_path: Path, +) -> None: result, post_log = _run_wake_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_settlement_falls_back_after_primary_wake_token_is_denied( + tmp_path: Path, +) -> None: + """A nonempty primary write token cannot shadow a working fallback.""" + result, post_log = _run_wake_step( + tmp_path, + env_overrides={ + "PR_REVIEW_MERGE_WAKE_TOKEN": "denied-token", + "OPENCODE_APPROVE_WAKE_TOKEN": "actions-token", + "FAKE_DENIED_TOKEN": "denied-token", + }, + ) + + assert result.returncode == 0, result.stderr + 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_reuses_authenticated_predecessor_receipt( + tmp_path: Path, +) -> None: + """Mixed matrices may combine a prior receipt with current direct evidence.""" + head_sha = "b" * 40 + base_sha = "a" * 40 + source_sha = "c" * 40 + statuses = [ + { + "context": f"codeql-dispatch/python/{base_sha}", + "description": ( + f"cwl1;h={head_sha};w=codeql-scan-dispatch;r=42;s={source_sha}" + ), + "target_url": ( + "https://github.com/ContextualWisdomLab/.github/actions/runs/99" + ), + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + } + ] + current_jobs = { + "jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + { + "name": "CodeQL dispatch scan (actions)", + "status": "completed", + "conclusion": "failure", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "failure"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ] + } + 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"}, + ], + }, + ] + } + + result, post_log = _run_wake_step( + tmp_path, + statuses=statuses, + producer_jobs=current_jobs, + producer_artifacts={ + "artifacts": [ + {"name": "codeql-dispatch-actions-100-1", "expired": False} + ] + }, + predecessor_jobs=predecessor_jobs, + predecessor_artifacts={ + "artifacts": [ + {"name": "codeql-dispatch-python-99-1", "expired": False} + ] + }, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs" + ] + + +@pytest.mark.parametrize( + ("receipt_state", "gate_steps"), + [ + ("success", []), + ( + "success", + [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}, + ], + ), + ( + "success", + [{"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "failure"}], + ), + ( + "failure", + [{"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "success"}], + ), + ( + "error", + [{"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "failure"}], + ), + ], +) +def test_dispatch_settlement_rejects_receipt_without_exact_matching_gate( + tmp_path: Path, receipt_state: str, gate_steps: list[dict[str, str]], +) -> None: + """A predecessor receipt must bind one gate outcome to its published state.""" + head_sha = "b" * 40 + base_sha = "a" * 40 + source_sha = "c" * 40 + statuses = [{ + "context": f"codeql-dispatch/python/{base_sha}", + "description": f"cwl1;h={head_sha};w=codeql-scan-dispatch;r=42;s={source_sha}", + "target_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/99", + "state": receipt_state, + "creator": {"login": "opencode-agent[bot]"}, + }] + predecessor_jobs = {"jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + { + "name": "CodeQL dispatch scan (python)", + "status": "completed", + "conclusion": "success" if receipt_state == "success" else "failure", + "run_attempt": 1, + "steps": [ + *gate_steps, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ]} + current_jobs = {"jobs": [ + {"name": "validate-dispatch", "status": "completed", "conclusion": "success"}, + { + "name": "CodeQL dispatch scan (actions)", + "status": "completed", + "conclusion": "failure", + "run_attempt": 1, + "steps": [ + {"name": "Enforce CodeQL Medium+ SARIF gate", "conclusion": "failure"}, + {"name": "Preserve CodeQL SARIF evidence", "conclusion": "success"}, + ], + }, + ]} + + result, post_log = _run_wake_step( + tmp_path, + statuses=statuses, + producer_jobs=current_jobs, + producer_artifacts={"artifacts": [ + {"name": "codeql-dispatch-actions-100-1", "expired": False} + ]}, + predecessor_jobs=predecessor_jobs, + predecessor_artifacts={"artifacts": [ + {"name": "codeql-dispatch-python-99-1", "expired": False} + ]}, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert "waiting for authenticated terminal receipts" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_reruns_whole_attempt_after_base_refresh( + tmp_path: Path, +) -> None: + """A refreshed base restarts successful capture and every matrix shard.""" + jobs = [ + { + "id": 43, "run_id": 42, "run_attempt": 1, "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", "conclusion": "success", + }, + { + "id": 44, "run_id": 42, "run_attempt": 1, "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", "conclusion": "failure", + }, + ] + + result, post_log = _run_wake_step( + tmp_path, + jobs=jobs, + rerun_mode="all", + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun" + ] + + +def test_dispatch_settlement_recovers_forward_base_advance_after_scan( + tmp_path: Path, +) -> None: + """A base advance after dispatch validation restarts the exact required run.""" + result, post_log = _run_wake_step( + tmp_path, + pull={ + "state": "open", + "head": {"sha": "b" * 40}, + "base": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", + "sha": "d" * 40, + }, + }, + base_compare={ + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "base_commit": {"sha": "a" * 40}, + "merge_base_commit": {"sha": "a" * 40}, + }, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun" + ] + + +def test_dispatch_settlement_rejects_nonforward_late_base_change( + tmp_path: Path, +) -> None: + """A rewritten or divergent base cannot authorize a whole-run restart.""" + result, post_log = _run_wake_step( + tmp_path, + pull={ + "state": "open", + "head": {"sha": "b" * 40}, + "base": { + "repo": {"full_name": "ContextualWisdomLab/naruon"}, + "ref": "main", + "sha": "d" * 40, + }, + }, + base_compare={ + "status": "diverged", + "ahead_by": 1, + "behind_by": 1, + "base_commit": {"sha": "a" * 40}, + "merge_base_commit": {"sha": "e" * 40}, + }, + ) + + assert result.returncode == 1 + assert "forward base advance" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_accepts_descendant_handler_source( + tmp_path: Path, +) -> None: + """Settlement authenticates a newer handler descended from producer source.""" + result, post_log = _run_wake_step( + tmp_path, + handler_source_sha="d" * 40, + source_compare={ + "status": "ahead", + "ahead_by": 1, + "behind_by": 0, + "base_commit": {"sha": "c" * 40}, + "merge_base_commit": {"sha": "c" * 40}, + }, + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "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( - tmp_path / "stale", pull={"state": "open", "head": {"sha": "c" * 40}} + tmp_path / "stale", + pull={ + "state": "open", "head": {"sha": "c" * 40}, + "base": {"sha": "a" * 40, "ref": "main"}, + }, ) closed_result, closed_log = _run_wake_step( - tmp_path / "closed", pull={"state": "closed", "head": {"sha": "b" * 40}} + tmp_path / "closed", + pull={ + "state": "closed", "head": {"sha": "b" * 40}, + "base": {"sha": "a" * 40, "ref": "main"}, + }, ) assert stale_result.returncode == 1 @@ -684,28 +1491,202 @@ 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={ - "id": 43, - "run_id": 999, +def test_dispatch_settlement_accepts_exact_scan_and_artifact_when_status_write_fails( + tmp_path: Path, +) -> None: + result, post_log = _run_wake_step(tmp_path, statuses=[]) + + assert result.returncode == 0, result.stderr + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/naruon/actions/runs/42/rerun-failed-jobs" + ] + + +def test_dispatch_settlement_reads_direct_evidence_on_later_pages( + tmp_path: Path, +) -> None: + """Settlement consumes complete paginated producer jobs and artifacts.""" + producer_jobs = [ + { + "jobs": [ + { + "name": "validate-dispatch", + "status": "completed", + "conclusion": "success", + } + ] + }, + { + "jobs": [ + { + "name": f"CodeQL dispatch scan ({language})", + "status": "completed", + "conclusion": "failure", + "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}-100-1", + "expired": False, + } + for language in ("python", "actions") + ] + }, + ] + + result, post_log = _run_wake_step( + tmp_path, + statuses=[], + 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/naruon/actions/runs/42/rerun-failed-jobs" + ] + + +def test_dispatch_settlement_waits_when_receipt_and_direct_evidence_are_missing( + tmp_path: Path, +) -> None: + result, post_log = _run_wake_step( + tmp_path, + statuses=[], + producer_jobs={"jobs": []}, + ) + + assert result.returncode == 0, result.stderr + assert "waiting for authenticated terminal receipts" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_accepts_exact_self_repository_workflow_token_receipts( + tmp_path: Path, +) -> None: + """The trusted handler accepts only its own exact-run GitHub-token fallback.""" + statuses = [ + { + "context": f"codeql-dispatch/{language}/{'a' * 40}", + "description": ( + f"cwl1;h={'b' * 40};w=codeql-scan-dispatch;r=42;" + f"s={'c' * 40}" + ), + "target_url": "https://github.com/ContextualWisdomLab/.github/actions/runs/100", + "state": "success", + "creator": {"login": "github-actions[bot]"}, + } + for language in ("python", "actions") + ] + result, post_log = _run_wake_step( + tmp_path, + pull={ + "state": "open", "head": {"sha": "b" * 40}, + "base": { + "repo": {"full_name": "ContextualWisdomLab/.github"}, + "sha": "a" * 40, + "ref": "main", + }, + }, + statuses=statuses, + producer_jobs={ + "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") + ] + }, + target_repository="ContextualWisdomLab/.github", + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/.github/actions/runs/42/rerun-failed-jobs" + ] + + +def test_dispatch_settlement_rejects_failed_job_outside_exact_language_map( + tmp_path: Path, +) -> None: + jobs = [ + { + "id": 43, "run_id": 42, "run_attempt": 1, "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", "conclusion": "failure", + }, + { + "id": 44, "run_id": 42, "run_attempt": 1, "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", "conclusion": "failure", + }, + { + "id": 45, "run_id": 42, "run_attempt": 1, "head_sha": "b" * 40, + "name": "Unrelated failed gate", + "status": "completed", "conclusion": "failure", + }, + ] + result, post_log = _run_wake_step(tmp_path, jobs=jobs) + + assert result.returncode == 1 + assert "failed jobs outside the exact language map" in result.stdout + assert not post_log.exists() + + +def test_dispatch_settlement_rejects_ambiguous_or_nonfailed_job_identity(tmp_path: Path) -> None: + wrong_jobs = [ + { + "id": 43, "run_id": 999, "run_attempt": 1, "head_sha": "b" * 40, "name": "CodeQL compatibility analysis (python)", - "status": "completed", - "conclusion": "failure", + "status": "completed", "conclusion": "failure", + }, + { + "id": 44, "run_id": 42, "run_attempt": 1, + "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", "conclusion": "failure", }, + ] + wrong_job_result, wrong_job_log = _run_wake_step( + tmp_path / "wrong-job", + 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_wake_step( tmp_path / "successful-job", - job={ - "id": 43, - "run_id": 42, - "head_sha": "b" * 40, - "name": "CodeQL compatibility analysis (python)", - "status": "completed", - "conclusion": "success", - }, + jobs=successful_jobs, ) assert wrong_job_result.returncode == 1 @@ -715,22 +1696,64 @@ def test_dispatch_wake_rejects_ambiguous_or_nonfailed_job_identity(tmp_path: Pat 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.""" +def test_dispatch_settlement_accepts_403_only_after_exact_new_attempt_proof( + tmp_path: Path, +) -> None: + """A sibling 403 is settled only when both exact jobs have newer attempts.""" + newer_jobs = [ + { + "id": 53, "run_id": 42, "run_attempt": 2, "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (python)", + "status": "in_progress", "conclusion": None, + }, + { + "id": 54, "run_id": 42, "run_attempt": 2, "head_sha": "b" * 40, + "name": "CodeQL compatibility analysis (actions)", + "status": "queued", "conclusion": None, + }, + ] result, post_log = _run_wake_step( tmp_path, - run={ - "id": 42, - "event": "pull_request", - "path": ".github/workflows/codeql-pr.yml", - "head_sha": "b" * 40, - "status": "in_progress", - "conclusion": None, - }, + post_failure=True, + settled_jobs=newer_jobs, ) assert result.returncode == 0, result.stderr assert post_log.exists() + assert "exact newer attempts" in result.stdout + + +def test_dispatch_settlement_rejects_bare_403_without_exact_new_attempts( + tmp_path: Path, +) -> None: + result, post_log = _run_wake_step(tmp_path, post_failure=True) + + assert result.returncode == 1 + assert post_log.exists() + assert "could not prove exact newer attempts" in result.stdout + + + +def test_codeql_settlement_paginates_direct_evidence_collections() -> None: + """Run-wide settlement must inspect every producer job and artifact page.""" + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + job_lines = [ + line + for line in workflow.splitlines() + if "/jobs?filter=latest&per_page=100" in line and "--slurp" in line + ] + artifact_lines = [ + line + for line in workflow.splitlines() + if "artifacts=" in line and "/artifacts?name=" in line + ] + + assert len(job_lines) == 2 + assert len(artifact_lines) == 2 + assert all("github_api --paginate --slurp" in line for line in job_lines) + assert all("github_api --paginate --slurp" in line for line in artifact_lines) + assert ".[]?.jobs[]?" in workflow + assert ".[]?.artifacts[]?" in workflow def test_codeql_scan_dispatch_serialises_the_matrix_payload() -> None: @@ -756,7 +1779,7 @@ def test_codeql_scan_dispatch_serialises_the_matrix_payload() -> None: "SUPPLIED_MATRIX: ${{ github.event.client_payload.matrix" not in workflow ), "SUPPLIED_MATRIX must not assign the raw client_payload array to env:" assert ( - "SUPPLIED_REQUIRED_JOBS: ${{ toJSON(github.event.client_payload.required_jobs) }}" + "SUPPLIED_REQUIRED_JOBS: ${{ toJSON(github.event.client_payload.rerun_request.required_jobs || github.event.client_payload.required_jobs) }}" in workflow ), "SUPPLIED_REQUIRED_JOBS must be serialised with toJSON(); a bare array breaks template validation" assert ( 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..640499b34f 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 all three CodeQL Scan Dispatch jobs to pin Ubuntu 24.04.""" workflow = CODEQL_SCAN_DISPATCH.read_text(encoding="utf-8") self.assertNotIn("runs-on: ubuntu-latest", workflow) - self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 2) + 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."""