diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index cb07ad2fab..db06b7c4ba 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -2,12 +2,12 @@ # refuses to admit it, 0/43+ across every sampled repository # (docs/doctoring/codeql-pr-required-workflow-always-fails.md). This file # stays required-workflow-safe by never calling codeql-action itself: it -# detects languages, dispatches the actual scan via repository_dispatch to -# codeql-scan-dispatch.yml (which runs natively, unrestricted, in -# ContextualWisdomLab/.github). The shard then fails intentionally to release -# its runner; the handler publishes codeql-dispatch/ and reruns only -# that exact failed job. On rerun the shard reads the terminal status once. -# Design: +# detects languages, fails each analyze-head shard pending to release its +# runner, then one coordinator POSTs repository_dispatch to +# codeql-scan-dispatch.yml (native, unrestricted, in +# ContextualWisdomLab/.github) with the remaining language matrix. The +# handler publishes codeql-dispatch/ and reruns only that exact +# failed job. On rerun the shard reads the terminal status once. Design: # docs/adr/0025-codeql-required-workflow-dispatch-architecture.md. The # merge-preview scan (analyze-merge) is required nowhere (PR #1766) and was # dropped, not migrated. @@ -156,28 +156,21 @@ jobs: fail-fast: false matrix: ${{ fromJSON(needs.detect-languages.outputs.matrix) }} steps: - - name: Request current-head CodeQL scan dispatch - # Each shard dispatches only its own language and passes its exact - # run/job identity. The shard intentionally fails after dispatch so - # its runner is released; the trusted handler later reruns that one - # failed job after publishing a terminal current-head verdict. + - name: Read current-head CodeQL dispatch verdict + # Shards never dispatch. They re-check the live head, consume an + # authenticated codeql-dispatch/ verdict when one exists, + # and otherwise fail pending so the runner is released. One + # coordinator job POSTs the remaining language matrix after every + # shard has a job id. id: dispatch if: needs.detect-languages.outputs.code == 'true' env: GH_TOKEN: ${{ github.token }} - OIDC_AUDIENCE: opencode-github-action - OPENCODE_API_BASE_URL: https://api.opencode.ai TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} LANGUAGE: ${{ matrix.language }} - BUILD_MODE: ${{ matrix.build-mode }} RUN_ATTEMPT: ${{ github.run_attempt }} - REQUIRED_RUN_ID: ${{ github.run_id }} - REQUIRED_JOB_ID: ${{ job.check_run_id }} run: | set -euo pipefail live_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" @@ -219,43 +212,6 @@ jobs: echo "::error::Exact CodeQL job was rerun without an authenticated terminal verdict." exit 1 fi - if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$REQUIRED_JOB_ID" =~ ^[1-9][0-9]*$ ]]; then - echo "::error::CodeQL dispatch requires canonical current run and job ids." - exit 1 - fi - - if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then - echo "::error::CodeQL scan dispatch requires GitHub OIDC." - exit 1 - fi - separator='&' - [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] || separator='?' - oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" - if [ -z "$oidc_token" ]; then - echo "::error::CodeQL scan dispatch could not obtain its OIDC token." - exit 1 - fi - app_token="$(curl -fsS -X POST -H "Authorization: Bearer ${oidc_token}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" - if [ -z "$app_token" ]; then - echo "::error::CodeQL scan dispatch could not obtain its repository-scoped app token." - exit 1 - fi - echo "::add-mask::$app_token" - jq -cn \ - --arg target_repository "$TARGET_REPOSITORY" \ - --arg pr_number "$PR_NUMBER" \ - --arg pr_base_ref "$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 language "$LANGUAGE" \ - --arg build_mode "$BUILD_MODE" \ - --arg required_run_id "$REQUIRED_RUN_ID" \ - --arg required_job_id "$REQUIRED_JOB_ID" \ - --arg required_language "$LANGUAGE" \ - '{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:[{language:$language,"build-mode":$build_mode}],required_run_id:$required_run_id,required_job_id:$required_job_id,required_language:$required_language}}' | - GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input - echo "verdict=pending" >>"$GITHUB_OUTPUT" - name: Release runner or enforce current-head CodeQL verdict @@ -287,3 +243,154 @@ jobs: exit 1 ;; esac + + dispatch-current-head: + name: Dispatch current-head CodeQL scan + needs: [detect-languages, analyze-head] + if: >- + always() + && github.event.action != 'closed' + && github.event.pull_request.state != 'closed' + && github.run_attempt == 1 + && needs.detect-languages.result == 'success' + && needs.detect-languages.outputs.code == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + actions: read + steps: + - name: Dispatch current-head CodeQL scan + env: + GH_TOKEN: ${{ github.token }} + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + TARGET_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REQUIRED_RUN_ID: ${{ github.run_id }} + MATRIX: ${{ needs.detect-languages.outputs.matrix }} + run: | + 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_state="$(printf '%s' "$live_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" + if [ -z "$live_head" ] || [ -z "$live_state" ]; then + echo "::error::Could not validate live pull request state before CodeQL dispatch." + exit 1 + fi + if [ "$live_state" = "closed" ]; then + echo "PR is closed on the live exact head; a current-head CodeQL scan is not requested." + exit 0 + fi + if [ "${live_head,,}" != "${PR_HEAD_SHA,,}" ]; then + echo "Pull request head moved on the live open PR; a fresh dispatch will fire for the current head." + exit 0 + fi + if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::CodeQL dispatch requires a canonical current run id." + exit 1 + fi + + include_json="$(printf '%s' "$MATRIX" | jq -c '.include // empty' 2>/dev/null || true)" + if [ -z "$include_json" ] || + [ "$(printf '%s' "$include_json" | jq 'type == "array" and length >= 1')" != "true" ]; then + echo "::error::CodeQL coordinator received an empty or malformed language matrix." + exit 1 + fi + + jobs_json="$( + gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${REQUIRED_RUN_ID}/jobs" --jq '.jobs[]' | + jq -s '{jobs:.}' + )" + required_jobs='[]' + while IFS= read -r entry; do + language="$(printf '%s' "$entry" | jq -r '.language // empty')" + expected_name="CodeQL compatibility analysis (${language})" + job_id="$(printf '%s' "$jobs_json" | jq -r --arg name "$expected_name" ' + [.jobs[]? | select(.name == $name) | .id] + | if length == 1 then .[0] | tostring else empty end + ')" + if ! [[ "$job_id" =~ ^[1-9][0-9]*$ ]]; then + echo "::error::CodeQL coordinator missing current-head job id for ${language}." + exit 1 + fi + required_jobs="$( + jq -c --arg language "$language" --argjson job_id "$job_id" \ + '. + [{language:$language,job_id:$job_id}]' <<<"$required_jobs" + )" + done < <(printf '%s' "$include_json" | jq -c '.[]') + + statuses="$(gh api "repos/${TARGET_REPOSITORY}/commits/${PR_HEAD_SHA}/statuses")" + pending_matrix='[]' + while IFS= read -r entry; do + language="$(printf '%s' "$entry" | jq -r '.language // empty')" + verdict_state="$(printf '%s' "$statuses" | jq -r --arg ctx "codeql-dispatch/${language}" ' + [ + .[] + | select(.context == $ctx) + | select( + (.creator.login // "" | ascii_downcase) as $creator + | $creator == "opencode-agent" or $creator == "opencode-agent[bot]" + ) + ] + | first // {} | .state // empty + ')" + case "$verdict_state" in + success|failure|error) + echo "Found authenticated current-head CodeQL verdict for ${language}: ${verdict_state}." + ;; + *) + pending_matrix="$(jq -c --argjson entry "$entry" '. + [$entry]' <<<"$pending_matrix")" + ;; + esac + done < <(printf '%s' "$include_json" | jq -c '.[]') + + if [ "$(printf '%s' "$pending_matrix" | jq 'length')" -eq 0 ]; then + echo "All detected CodeQL languages already have authenticated terminal verdicts; skipping dispatch." + exit 0 + fi + + required_jobs="$( + jq -nc --argjson pending "$pending_matrix" --argjson jobs "$required_jobs" ' + ($pending | map(.language)) as $langs + | [$jobs[] | select(.language as $l | $langs | index($l) != null)] + ' + )" + if [ "$(printf '%s' "$required_jobs" | jq 'length')" != "$(printf '%s' "$pending_matrix" | jq 'length')" ]; then + echo "::error::CodeQL coordinator could not bind a job id to every pending language." + exit 1 + fi + + if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "::error::CodeQL scan dispatch requires GitHub OIDC." + exit 1 + fi + separator='&' + [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] || separator='?' + oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" + if [ -z "$oidc_token" ]; then + echo "::error::CodeQL scan dispatch could not obtain its OIDC token." + exit 1 + fi + app_token="$(curl -fsS -X POST -H "Authorization: Bearer ${oidc_token}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" + if [ -z "$app_token" ]; then + echo "::error::CodeQL scan dispatch could not obtain its repository-scoped app token." + exit 1 + fi + echo "::add-mask::$app_token" + jq -cn \ + --arg target_repository "$TARGET_REPOSITORY" \ + --arg pr_number "$PR_NUMBER" \ + --arg pr_base_ref "$PR_BASE_REF" \ + --arg pr_base_sha "$PR_BASE_SHA" \ + --arg pr_head_ref "$PR_HEAD_REF" \ + --arg pr_head_sha "$PR_HEAD_SHA" \ + --argjson matrix "$pending_matrix" \ + --arg required_run_id "$REQUIRED_RUN_ID" \ + --argjson required_jobs "$required_jobs" \ + '{event_type:"codeql-scan",client_payload:{target_repository:$target_repository,pr_number:$pr_number,pr_base_ref:$pr_base_ref,pr_base_sha:$pr_base_sha,pr_head_ref:$pr_head_ref,pr_head_sha:$pr_head_sha,matrix:$matrix,required_run_id:$required_run_id,required_jobs:$required_jobs}}' | + GH_TOKEN="$app_token" gh api -X POST repos/ContextualWisdomLab/.github/dispatches --input - diff --git a/.github/workflows/codeql-scan-dispatch.yml b/.github/workflows/codeql-scan-dispatch.yml index 1c9dda3e45..23c47f6abc 100644 --- a/.github/workflows/codeql-scan-dispatch.yml +++ b/.github/workflows/codeql-scan-dispatch.yml @@ -26,8 +26,7 @@ concurrency: group: >- codeql-scan-dispatch-${{ github.event.client_payload.target_repository || github.repository }}-${{ - github.event.client_payload.pr_number || github.run_id }}-${{ - github.event.client_payload.required_language || 'unknown-language' }} + github.event.client_payload.pr_number || github.run_id }} cancel-in-progress: true permissions: @@ -50,8 +49,7 @@ jobs: head_sha: ${{ steps.validate.outputs.head_sha }} matrix: ${{ steps.validate.outputs.matrix }} required_run_id: ${{ steps.validate.outputs.required_run_id }} - required_job_id: ${{ steps.validate.outputs.required_job_id }} - required_language: ${{ steps.validate.outputs.required_language }} + required_jobs: ${{ steps.validate.outputs.required_jobs }} steps: - name: Exchange OpenCode app token for target repository metadata reads id: metadata_read_app_token @@ -148,8 +146,7 @@ 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_JOB_ID: ${{ github.event.client_payload.required_job_id || '' }} - SUPPLIED_REQUIRED_LANGUAGE: ${{ github.event.client_payload.required_language || '' }} + SUPPLIED_REQUIRED_JOBS: ${{ toJSON(github.event.client_payload.required_jobs) }} run: | set -euo pipefail # ALLOWED_DISPATCH_ACTOR is a comma-separated allowlist shared with @@ -180,19 +177,37 @@ jobs: fi matrix_json="$(printf '%s' "$SUPPLIED_MATRIX" | jq -c '.' 2>/dev/null || true)" + jobs_json="$(printf '%s' "$SUPPLIED_REQUIRED_JOBS" | jq -c '.' 2>/dev/null || true)" if [ -z "$matrix_json" ] || - [ "$(printf '%s' "$matrix_json" | jq 'type == "array" and length == 1')" != "true" ] || - [ "$(printf '%s' "$matrix_json" | jq '[.[] | select((.language | type == "string") and (.language | test("^[a-z0-9-]+$")) and (."build-mode" | type == "string"))] | length == ($ARGS.positional[0] | tonumber)' --args "$(printf '%s' "$matrix_json" | jq 'length')")" != "true" ]; then - printf '::error::CodeQL scan dispatch matrix must contain exactly one valid language/build-mode shard. matrix=%s\n' "${SUPPLIED_MATRIX:-}" + [ "$(printf '%s' "$matrix_json" | jq 'type == "array" and length >= 1')" != "true" ] || + [ "$(printf '%s' "$matrix_json" | jq '[.[] | select((.language | type == "string") and (.language | test("^[a-z0-9-]+$")) and (."build-mode" | type == "string"))] | length == ($ARGS.positional[0] | tonumber)' --args "$(printf '%s' "$matrix_json" | jq 'length')")" != "true" ] || + [ "$(printf '%s' "$matrix_json" | jq '(map(.language) | unique | length) == (map(.language) | length)')" != "true" ]; then + printf '::error::CodeQL scan dispatch matrix must contain at least one valid language/build-mode shard with unique languages. matrix=%s\n' "${SUPPLIED_MATRIX:-}" exit 1 fi - matrix_language="$(printf '%s' "$matrix_json" | jq -r '.[0].language // empty')" - if ! [[ "$SUPPLIED_REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]] || - ! [[ "$SUPPLIED_REQUIRED_JOB_ID" =~ ^[1-9][0-9]*$ ]] || - [ "$SUPPLIED_REQUIRED_LANGUAGE" != "$matrix_language" ]; then - printf '::error::CodeQL wake identity is missing, non-canonical, or does not match the dispatched language.\n' + if [ -z "$jobs_json" ] || + [ "$(jq -n --argjson matrix "$matrix_json" --argjson jobs "$jobs_json" ' + ($jobs | type == "array") + and (($jobs | length) == ($matrix | length)) + and ($jobs | all( + (.language | type == "string") + and (.language | test("^[a-z0-9-]+$")) + and ( + ((.job_id | type == "number") and (.job_id == (.job_id | floor)) and (.job_id >= 1)) + or ((.job_id | type == "string") and (.job_id | test("^[1-9][0-9]*$"))) + ) + )) + and (($jobs | map(.language) | sort) == ($matrix | map(.language) | sort)) + and (($jobs | map(.language) | unique | length) == ($jobs | length)) + ')" != "true" ]; then + printf '::error::CodeQL wake identity is missing, non-canonical, or does not match the dispatched languages one-to-one.\n' exit 1 fi + if ! [[ "$SUPPLIED_REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + printf '::error::CodeQL wake identity is missing, non-canonical, or does not match the dispatched languages one-to-one.\n' + exit 1 + fi + jobs_json="$(printf '%s' "$jobs_json" | jq -c 'map({language, job_id: (.job_id | tonumber)})')" pull_request_json="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" live_base_repository="$(jq -r '.base.repo.full_name // empty' <<<"$pull_request_json")" @@ -235,8 +250,9 @@ jobs: printf '%s\n' "$matrix_json" echo "EOF" printf 'required_run_id=%s\n' "$SUPPLIED_REQUIRED_RUN_ID" - printf 'required_job_id=%s\n' "$SUPPLIED_REQUIRED_JOB_ID" - printf 'required_language=%s\n' "$SUPPLIED_REQUIRED_LANGUAGE" + echo "required_jobs<>"$GITHUB_OUTPUT" printf 'Validated current live metadata for %s#%s: base=%s/%s head=%s/%s.\n' "$TARGET_REPOSITORY" "$PR_NUMBER" "$live_base_ref" "$live_base_sha" "$live_head_ref" "$live_head_sha" @@ -484,16 +500,16 @@ jobs: && needs.validate-dispatch.outputs.target_repository != '' && needs.validate-dispatch.outputs.pr_number != '' && needs.validate-dispatch.outputs.head_sha != '' - && github.event.client_payload.required_run_id != '' - && github.event.client_payload.required_job_id != '' + && needs.validate-dispatch.outputs.required_run_id != '' + && needs.validate-dispatch.outputs.required_jobs != '' env: GH_TOKEN: ${{ needs.validate-dispatch.outputs.target_repository == github.repository && github.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }} TARGET_REPOSITORY: ${{ needs.validate-dispatch.outputs.target_repository }} PR_NUMBER: ${{ needs.validate-dispatch.outputs.pr_number }} HEAD_SHA: ${{ needs.validate-dispatch.outputs.head_sha }} REQUIRED_RUN_ID: ${{ needs.validate-dispatch.outputs.required_run_id }} - REQUIRED_JOB_ID: ${{ needs.validate-dispatch.outputs.required_job_id }} - REQUIRED_LANGUAGE: ${{ needs.validate-dispatch.outputs.required_language }} + REQUIRED_JOBS: ${{ needs.validate-dispatch.outputs.required_jobs }} + REQUIRED_LANGUAGE: ${{ matrix.language }} WAKE_TOKEN_SOURCE: ${{ needs.validate-dispatch.outputs.target_repository == github.repository && 'github-token' || secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'unavailable' }} run: | set -euo pipefail @@ -501,6 +517,10 @@ jobs: echo "::error::Actions-capable CodeQL wake credential is unavailable." exit 1 fi + REQUIRED_JOB_ID="$(printf '%s' "$REQUIRED_JOBS" | jq -r --arg lang "$REQUIRED_LANGUAGE" ' + [.[] | select(.language == $lang) | .job_id | tostring] + | if length == 1 and (.[0] | test("^[1-9][0-9]*$")) then .[0] else empty end + ')" if ! [[ "$REQUIRED_RUN_ID" =~ ^[1-9][0-9]*$ ]] || ! [[ "$REQUIRED_JOB_ID" =~ ^[1-9][0-9]*$ ]] || ! [[ "$REQUIRED_LANGUAGE" =~ ^[a-z0-9-]+$ ]]; then diff --git a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md index 065a9d4d0f..5a11894767 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 · **Date:** 2026-09-03 · **Owner intent recorded:** loop-brief item 41 +**Status:** Proposed, amended 2026-09-07 (one dispatch per pull request; language independence is the handler job matrix) · **Date:** 2026-09-03 · **Owner intent recorded:** loop-brief item 41 ## Problem @@ -96,14 +96,29 @@ codeql-pr.yml (required workflow, runs in target repo context) does) before dispatching. analyze-head (matrix) -- SAME REQUIRED-CHECK NAME: "CodeQL compatibility analysis (${{ matrix.language }})". - No codeql-action reference. On attempt one it - dispatches its exact run id, job id, language, - and head, then fails intentionally to release - the runner. The trusted handler publishes the - terminal status and reruns only that failed - job. On attempt two the shard reads the - authenticated current-head status once and - reflects it as this job's own exit code. + No codeql-action reference and no + repository_dispatch. On attempt one it + re-checks the live head, consumes an + authenticated codeql-dispatch/ + status when one exists, and otherwise fails + pending to release the runner. The trusted + handler publishes the terminal status and + reruns only that failed job. On the woken + attempt the shard reads the authenticated + current-head status once and reflects it as + this job's own exit code. + dispatch-current-head -- NEW: needs analyze-head, runs on attempt one + of an open current-head PR after the shards + have job ids. Collects those ids from this + run's jobs API, POSTs event_type codeql-scan + once with the remaining language matrix and + required_jobs: [{language, job_id}, ...], and + fails closed if any shard job id is missing. + Skips the POST when every language already + has a terminal verdict. github.run_attempt == 1 + is required: a single-job wake re-runs + dependents, and a second POST would cancel + the in-flight multi-language handler. .github/workflows/codeql-scan-dispatch.yml (NEW, runs natively in .github, NOT admitted through the ruleset, so codeql-action is unrestricted here) @@ -151,24 +166,49 @@ NOT admitted through the ruleset, so codeql-action is unrestricted here) closed and leaves the required job failed. ``` -### Concurrency identity is per pull request and language shard +### Concurrency identity is per pull request; language independence is the job matrix -Each required `analyze-head` matrix job dispatches one language and supplies a -matching `required_language`. The native handler therefore serializes only the -same repository, pull request, and language tuple. A newer dispatch for that -tuple cancels its stale predecessor, while Python, JavaScript/TypeScript, and -Actions scans for the same head remain independent. +The required `analyze-head` matrix still publishes one named check per +language. It no longer POSTs. One `dispatch-current-head` job sends every +still-pending language in a single `codeql-scan` payload (`matrix` plus +`required_jobs`). The native handler's concurrency group is +`codeql-scan-dispatch-${target_repository}-${pr_number}` with +`cancel-in-progress: true`, so a newer HEAD of the same pull request cancels +its predecessor and other repositories or pull requests stay independent. -This distinction is required by the exact-job wake contract. On 2026-09-05, -contextual-orchestrator PR #1049 dispatched all three current-head language -jobs, but central run `33938784437` was the sole survivor because the handler's -group omitted `required_language`. The sibling runs cancelled one another, -leaving their required jobs failed in the documented `pending` handoff state. -The chosen key adds the already validated language to the existing workflow, -repository, and pull-request identity. Sending the full language matrix in one -dispatch was rejected because the handler validates one shard and wakes one -exact required job per run; changing that contract would enlarge the security -and recovery surface without solving another observed need. +Language independence is `strategy.fail-fast: false` on that one run's job +matrix. Each scan job still publishes `codeql-dispatch/` and wakes +only its own required job. One language's failure cannot cancel or skip a +sibling. + +#### 2026-09-07 amendment: one dispatch per pull request, adopted for the 60-job ceiling + +The 2026-09-05 per-language run was the right fix for the accident it +recorded. contextual-orchestrator PR #1049 dispatched three current-head +language jobs, and central run `33938784437` was the sole survivor because +the handler's group omitted `required_language`. Sibling runs cancelled one +another and left their required jobs failed in the `pending` handoff state. +Sending the full language matrix in one dispatch was rejected then because +the handler validated one shard and woke one exact required job per run; +enlarging that surface had no observed need. + +That need now exists. On 2026-09-07 the organization job ceiling (60 jobs) +was saturated by this fan-out: ContextualWisdomLab/.github had ~300 queued +runs, 149 of them `codeql-scan-dispatch.yml`, covering 60 PR@SHA tuples +(n=2:29, n=3:27, n=4:2). Duplicate cancellation could not collapse them: +the language is not present on the run name, the job name, or the REST +payload. The user-facing concurrency contract for pull-request workflows is +`{workflow}-{repository}-{PR}` with `cancel-in-progress: true` only for a +superseded HEAD of the same pull request, and a language suffix is +forbidden. + +The 2026-09-05 rejection of "full matrix in one dispatch" is therefore +superseded. The sibling-cancel failure mode is gone because siblings are +jobs in one run, not runs in one concurrency group. The exact-job wake +contract is preserved: `required_jobs` is a 1:1 map of language to canonical +job id, each scan shard looks up only its own id, and a missing, stale, or +mismatched identity still fails closed. The old scalar +`required_job_id`/`required_language` payload is retired. ## Scope decision: `analyze-merge` is dropped, not migrated @@ -240,9 +280,10 @@ blocker for this one. inline Python between `analyze-head`/`analyze-merge` today. exact run/job wake-up follows the OpenCode runner-release pattern while avoiding one occupied runner per language for the scan's full duration. -- A repository and pull request can now have one active native handler per - language. This modest concurrency increase is bounded by the detected CodeQL - matrix and prevents valid sibling evidence from being treated as stale work. +- A repository and pull request have one active native handler run. Language + parallelism is bounded by the detected CodeQL matrix inside that run, and a + superseded HEAD of the same pull request cancels the in-flight handler + instead of queuing another copy per language. - Re-admitting `codeql-pr.yml` to ruleset `18156473` must happen only after this design is implemented, tested, and its `detect-languages`/ `dispatch-analysis`/`analyze-head` jobs are confirmed free of any diff --git a/tests/test_codeql_pr_workflow_contract.py b/tests/test_codeql_pr_workflow_contract.py index 90612e9bc8..2d11ca0141 100644 --- a/tests/test_codeql_pr_workflow_contract.py +++ b/tests/test_codeql_pr_workflow_contract.py @@ -59,25 +59,49 @@ def test_codeql_pr_workflow_structure() -> None: assert "commits/${PR_HEAD_SHA}/statuses" in workflow -def test_codeql_pr_dispatches_one_language_per_shard_not_the_full_matrix() -> None: - """Every shard dispatches, but only its own language, not the full matrix. - - Each shard carries its own run, job, language, and head identity so the - trusted dispatcher can wake only that intentionally failed job. +def test_codeql_pr_shards_do_not_dispatch_and_coordinator_sends_the_full_matrix_once() -> None: + """Shards consume verdicts; one coordinator POSTs the remaining language matrix. + + Per-language repository_dispatch runs were the 60-job ceiling: live + 2026-09-07 queued ~149 ``codeql-scan-dispatch.yml`` runs across 60 PR@SHA + tuples because each analyze-head shard POSTed its own ``codeql-scan``. + Language independence now lives in the handler's job matrix, so the + required workflow may send every still-pending language in one payload. """ workflow = WORKFLOW_PATH.read_text(encoding="utf-8") - - assert "id: dispatch" in workflow - assert 'matrix:[{language:$language,"build-mode":$build_mode}]' in workflow - assert "needs.detect-languages.outputs.matrix).include[0]" not in workflow - assert "DISPATCH_OUTCOME: ${{ steps.dispatch.outcome }}" in workflow - assert workflow.count("- name: Request current-head CodeQL scan dispatch") == 1 + analyze_head = workflow.split(" analyze-head:\n", 1)[1].split( + " dispatch-current-head:\n", 1 + )[0] + coordinator = workflow.split(" dispatch-current-head:\n", 1)[1] + + assert "id: dispatch" in analyze_head + assert "repos/ContextualWisdomLab/.github/dispatches" not in analyze_head + assert 'event_type:"codeql-scan"' not in analyze_head + assert 'matrix:[{language:$language,"build-mode":$build_mode}]' not in workflow + assert "required_job_id:$required_job_id" not in analyze_head + assert "required_language:$required_language" not in analyze_head + assert "DISPATCH_OUTCOME: ${{ steps.dispatch.outcome }}" in analyze_head + assert workflow.count("- name: Read current-head CodeQL dispatch verdict") == 1 assert workflow.count("- name: Release runner or enforce current-head CodeQL verdict") == 1 + assert workflow.count("- name: Dispatch current-head CodeQL scan") == 1 + assert "needs: [detect-languages, analyze-head]" in coordinator + assert "always()" in coordinator.split("\n runs-on:", 1)[0] + assert "github.event.action != 'closed'" in coordinator.split("\n runs-on:", 1)[0] + assert "github.run_attempt == 1" in coordinator.split("\n runs-on:", 1)[0] + assert coordinator.count("repos/ContextualWisdomLab/.github/dispatches") == 1 + assert 'event_type:"codeql-scan"' in coordinator + assert "required_jobs:$required_jobs" in coordinator + assert "required_run_id:$required_run_id" in coordinator + assert "required_job_id:$required_job_id" not in coordinator + assert "required_language:$required_language" not in coordinator + assert "actions/runs/${REQUIRED_RUN_ID}/jobs" in coordinator + assert "CodeQL compatibility analysis (" in coordinator RUN_BLOCK_STEP_NAMES = ( - "Request current-head CodeQL scan dispatch", + "Read current-head CodeQL dispatch verdict", "Release runner or enforce current-head CodeQL verdict", + "Dispatch current-head CodeQL scan", ) @@ -103,8 +127,9 @@ def test_codeql_pr_dispatch_and_release_run_blocks_are_valid_bash() -> None: assert result.returncode == 0, f"{step_name}: {result.stderr}" -DISPATCH_STEP_NAME = "Request current-head CodeQL scan dispatch" +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" def _run_verdict_read( @@ -238,19 +263,21 @@ def test_codeql_action_steps_use_one_version_per_workflow() -> None: assert len(refs) == 1, f"scheduled-security-scan.yml mixes CodeQL action refs: {sorted(refs)}" -def test_codeql_shard_releases_runner_and_dispatches_exact_wake_identity() -> None: +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] + 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" in shard - assert "required_run_id:$required_run_id" in shard - assert "required_job_id:$required_job_id" in shard - assert "required_language:$required_language" 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: @@ -259,6 +286,335 @@ def test_codeql_required_workflow_does_not_gain_actions_write() -> None: 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) + head_sha = "b" * 40 + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + post_log = tmp_path / "posts" + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'test "$1" = api\n' + 'if [ "${2:-}" = "-X" ]; then\n' + ' printf \'%s\\n\' "$4" >>"$FAKE_POST_LOG"\n' + " exit 0\n" + "fi\n" + 'case "$2" in\n' + " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" + " */statuses) printf '%s\\n' \"$FAKE_STATUSES_JSON\" ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + output = tmp_path / "github-output" + env = { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "FAKE_PULL_JSON": json.dumps({"head": {"sha": head_sha}, "state": "open"}), + "FAKE_STATUSES_JSON": json.dumps([]), + "FAKE_POST_LOG": str(post_log), + "GH_TOKEN": "fake-token", + "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "PR_NUMBER": "42", + "PR_HEAD_SHA": head_sha, + "LANGUAGE": "python", + "BUILD_MODE": "none", + "RUN_ATTEMPT": "1", + "REQUIRED_RUN_ID": "42", + "GITHUB_OUTPUT": str(output), + } + dispatch_result = subprocess.run( + [bash], input=dispatch_script, text=True, capture_output=True, check=False, + env=env, timeout=60, + ) + assert dispatch_result.returncode == 0, dispatch_result.stderr + assert "verdict=pending" in output.read_text(encoding="utf-8") + assert not post_log.exists() + verdict_result = subprocess.run( + [bash], + input=verdict_script, + text=True, + capture_output=True, + check=False, + env={ + **os.environ, + "LANGUAGE": "python", + "DISPATCH_OUTCOME": "success", + "VERDICT_STATE": "pending", + }, + timeout=60, + ) + assert verdict_result.returncode == 1 + assert "CodeQL scan dispatched" in verdict_result.stdout + + +def _write_coordinator_fakes( + tmp_path: Path, + *, + pull: dict, + jobs: dict, + statuses: list[dict], +) -> tuple[Path, Path, Path]: + """Install fake gh/curl binaries and return (bin, post_log, post_body).""" + fake_bin = tmp_path / "bin" + fake_bin.mkdir(parents=True) + post_log = tmp_path / "posts" + post_body = tmp_path / "post-body" + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'test "$1" = api\n' + "shift\n" + "method=GET\n" + "path=\n" + "jq_filter=\n" + "while [ $# -gt 0 ]; do\n" + ' case "$1" in\n' + " -X) shift; method=$1 ;;\n" + " --input) shift; input=$1 ;;\n" + " --jq|-q) shift; jq_filter=$1 ;;\n" + " --paginate) ;;\n" + ' repos/*) path=$1 ;;\n' + " esac\n" + " shift || true\n" + "done\n" + 'if [ "$method" = POST ]; then\n' + ' printf \'%s\\n\' "$path" >>"$FAKE_POST_LOG"\n' + ' if [ "${input:-}" = "-" ]; then cat >>"$FAKE_POST_BODY"; fi\n' + " exit 0\n" + "fi\n" + "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" + " *) 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', + encoding="utf-8", + ) + fake_gh.chmod(0o755) + fake_curl = fake_bin / "curl" + fake_curl.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'printf \'%s\\n\' "$*" >>"$FAKE_CURL_LOG"\n' + 'if [[ " $* " == *"exchange_github_app_token"* ]]; then\n' + " printf '%s\\n' '{\"token\":\"fake-app-token\"}'\n" + " exit 0\n" + "fi\n" + "printf '%s\\n' '{\"value\":\"fake-oidc-token\"}'\n", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + (tmp_path / "pull.json").write_text(json.dumps(pull), encoding="utf-8") + (tmp_path / "jobs.json").write_text(json.dumps(jobs), encoding="utf-8") + (tmp_path / "statuses.json").write_text(json.dumps(statuses), encoding="utf-8") + return fake_bin, post_log, post_body + + +def _run_coordinator( + tmp_path: Path, + *, + pull: dict | None = None, + jobs: dict | None = None, + statuses: list[dict] | None = None, + env_overrides: dict[str, str] | None = None, +) -> tuple[subprocess.CompletedProcess[str], Path, Path]: + """Execute the coordinator dispatch block against fixture-backed APIs.""" + 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, "ref": "feature"}, + "base": {"sha": "a" * 40, "ref": "main"}, + } + jobs = jobs or { + "total_count": 2, + "jobs": [ + { + "id": 101, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "failure", + }, + { + "id": 102, + "name": "CodeQL compatibility analysis (actions)", + "status": "completed", + "conclusion": "failure", + }, + ], + } + 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 + ) + script = _extract_run_block( + WORKFLOW_PATH.read_text(encoding="utf-8"), COORDINATOR_STEP_NAME + ) + env = { + **os.environ, + "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_POST_LOG": str(post_log), + "FAKE_POST_BODY": str(post_body), + "FAKE_CURL_LOG": str(tmp_path / "curl.log"), + "GH_TOKEN": "fake-token", + "TARGET_REPOSITORY": "ContextualWisdomLab/naruon", + "GITHUB_REPOSITORY": "ContextualWisdomLab/naruon", + "PR_NUMBER": "42", + "PR_BASE_REF": "main", + "PR_BASE_SHA": "a" * 40, + "PR_HEAD_REF": "feature", + "PR_HEAD_SHA": head_sha, + "REQUIRED_RUN_ID": "99", + "MATRIX": json.dumps( + { + "include": [ + {"language": "python", "build-mode": "none"}, + {"language": "actions", "build-mode": "none"}, + ] + } + ), + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "oidc-request-token", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://example.test/oidc", + "OIDC_AUDIENCE": "opencode-github-action", + "OPENCODE_API_BASE_URL": "https://api.opencode.ai", + **(env_overrides or {}), + } + result = subprocess.run( + [bash], input=script, text=True, capture_output=True, check=False, env=env, + timeout=60, + ) + return result, post_log, post_body + + +def test_codeql_coordinator_posts_one_dispatch_for_every_pending_language( + tmp_path: Path, +) -> None: + """One repository_dispatch carries every language that still needs a scan.""" + result, post_log, post_body = _run_coordinator(tmp_path) + + assert result.returncode == 0, result.stderr + result.stdout + assert post_log.read_text(encoding="utf-8").splitlines() == [ + "repos/ContextualWisdomLab/.github/dispatches" + ] + payload = json.loads(post_body.read_text(encoding="utf-8")) + assert payload["event_type"] == "codeql-scan" + client = payload["client_payload"] + assert client["target_repository"] == "ContextualWisdomLab/naruon" + assert client["pr_number"] == "42" + assert client["required_run_id"] == "99" + 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"] + } + assert jobs_by_language == {"python": 101, "actions": 102} + + +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.""" + result, post_log, post_body = _run_coordinator( + tmp_path, + statuses=[ + { + "context": "codeql-dispatch/python", + "state": "success", + "creator": {"login": "opencode-agent[bot]"}, + }, + { + "context": "codeql-dispatch/actions", + "state": "failure", + "creator": {"login": "opencode-agent[bot]"}, + }, + ], + ) + + assert result.returncode == 0, result.stderr + result.stdout + assert not post_log.exists() + assert not post_body.exists() or post_body.read_text(encoding="utf-8") == "" + assert "already have authenticated terminal verdicts" in result.stdout + + +def test_codeql_coordinator_fails_closed_when_a_shard_job_id_is_missing( + tmp_path: Path, +) -> None: + """A matrix language with no analyze-head job cannot be woken later.""" + result, post_log, _post_body = _run_coordinator( + tmp_path, + jobs={ + "total_count": 1, + "jobs": [ + { + "id": 101, + "name": "CodeQL compatibility analysis (python)", + "status": "completed", + "conclusion": "failure", + } + ], + }, + ) + + assert result.returncode == 1 + assert "missing current-head job id" in result.stdout + assert not post_log.exists() + + +def test_codeql_coordinator_does_not_dispatch_a_closed_or_stale_pull_request( + tmp_path: Path, +) -> None: + """Live-head revalidation remains fail-closed before the single POST.""" + closed, closed_log, _closed_body = _run_coordinator( + tmp_path / "closed", + pull={ + "state": "closed", + "head": {"sha": "b" * 40, "ref": "feature"}, + "base": {"sha": "a" * 40, "ref": "main"}, + }, + ) + stale, stale_log, _stale_body = _run_coordinator( + tmp_path / "stale", + pull={ + "state": "open", + "head": {"sha": "c" * 40, "ref": "feature"}, + "base": {"sha": "a" * 40, "ref": "main"}, + }, + ) + + assert closed.returncode == 0, closed.stderr + assert stale.returncode == 0, stale.stderr + assert not closed_log.exists() + assert not stale_log.exists() diff --git a/tests/test_codeql_scan_dispatch_workflow_contract.py b/tests/test_codeql_scan_dispatch_workflow_contract.py index 71fa43541f..2b3208f6a0 100644 --- a/tests/test_codeql_scan_dispatch_workflow_contract.py +++ b/tests/test_codeql_scan_dispatch_workflow_contract.py @@ -95,18 +95,29 @@ def test_codeql_scan_dispatch_workflow_structure(): def test_codeql_scan_dispatch_keeps_current_head_language_shards_independent(): - """A current-head language scan cannot cancel its sibling language scans.""" + """Sibling languages stay independent as jobs in one run, not as separate runs. + + The 60-job ceiling was one queued handler run per language. Putting + ``required_language`` in the concurrency group was the 2026-09-05 + workaround after contextual-orchestrator#1049 / run 33938784437 cancelled + sibling scans. Independence now comes from ``strategy.fail-fast: false`` + on this run's language matrix, so the group can be + ``{workflow}-{repository}-{PR}`` and ``cancel-in-progress: true`` only + drops a superseded HEAD of the same pull request. + """ workflow = WORKFLOW_PATH.read_text(encoding="utf-8") group_value = workflow_level_concurrency_group(workflow) + header = workflow.split("\non:", 1)[0] + scan = workflow.split(" scan:\n", 1)[1] + strategy = scan.split(" strategy:\n", 1)[1].split(" steps:\n", 1)[0] - # The language segment is what keeps sibling language shards in separate groups, so it is - # asserted on the group's own value: a comment naming it would otherwise satisfy the check - # while the key had lost it, silently letting one language's scan cancel another's. assert "github.event.client_payload.target_repository" in group_value assert "github.event.client_payload.pr_number" in group_value - assert "github.event.client_payload.required_language" in group_value - # Same reasoning as the group above, applied to the flag: the substring form - # is satisfied by a comment quoting it while the key beside it reads false. + assert "github.event.client_payload.required_language" not in group_value + assert "unknown-language" not in group_value + assert "required_language" not in header + assert "fail-fast: false" in strategy + assert "include: ${{ fromJSON(needs.validate-dispatch.outputs.matrix) }}" in strategy assert workflow_level_cancels_in_progress(workflow) @@ -148,8 +159,7 @@ def _run_validate_step(tmp_path: Path, env_overrides: dict[str, str], pull_reque "SUPPLIED_HEAD_SHA": "b" * 40, "SUPPLIED_MATRIX": json.dumps([{"language": "python", "build-mode": "none"}]), "SUPPLIED_REQUIRED_RUN_ID": "42", - "SUPPLIED_REQUIRED_JOB_ID": "43", - "SUPPLIED_REQUIRED_LANGUAGE": "python", + "SUPPLIED_REQUIRED_JOBS": json.dumps([{"language": "python", "job_id": 43}]), **env_overrides, } result = subprocess.run([bash], input=script, text=True, capture_output=True, check=False, env=env) @@ -177,8 +187,9 @@ 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 "required_job_id=43" in output_text - assert "required_language=python" in output_text + assert '"job_id":43' in output_text.replace(" ", "") + assert "required_job_id=" not in output_text + assert "required_language=" not in output_text def test_codeql_scan_dispatch_validate_step_rejects_actor_mismatch(tmp_path): @@ -270,15 +281,78 @@ def test_codeql_scan_dispatch_validate_step_rejects_non_org_target(tmp_path): def test_codeql_scan_dispatch_validate_step_rejects_malformed_matrix(tmp_path): - """A matrix entry missing a valid language/build-mode fails closed.""" + """Empty, invalid, or job-map-mismatched matrices fail closed; a multi-language payload is valid.""" + missing_build_mode = _run_validate_step( + tmp_path / "missing-build-mode", + {"SUPPLIED_MATRIX": json.dumps([{"language": "python"}])}, + _matching_pull_request(), + ) + empty_matrix = _run_validate_step( + tmp_path / "empty", + { + "SUPPLIED_MATRIX": "[]", + "SUPPLIED_REQUIRED_JOBS": "[]", + }, + _matching_pull_request(), + ) + invalid_language = _run_validate_step( + tmp_path / "invalid-language", + { + "SUPPLIED_MATRIX": json.dumps([{"language": "PYTHON", "build-mode": "none"}]), + "SUPPLIED_REQUIRED_JOBS": json.dumps([{"language": "PYTHON", "job_id": 43}]), + }, + _matching_pull_request(), + ) + mismatched_jobs = _run_validate_step( + tmp_path / "mismatched-jobs", + { + "SUPPLIED_MATRIX": json.dumps( + [ + {"language": "python", "build-mode": "none"}, + {"language": "actions", "build-mode": "none"}, + ] + ), + "SUPPLIED_REQUIRED_JOBS": json.dumps([{"language": "python", "job_id": 43}]), + }, + _matching_pull_request(), + ) + + assert missing_build_mode.returncode == 1 + assert empty_matrix.returncode == 1 + assert invalid_language.returncode == 1 + assert mismatched_jobs.returncode == 1 + 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 + + +def test_codeql_scan_dispatch_validate_step_accepts_multi_language_payload(tmp_path): + """One dispatch may carry every remaining language for the current head.""" result = _run_validate_step( tmp_path, - {"SUPPLIED_MATRIX": json.dumps([{"language": "python"}])}, + { + "SUPPLIED_MATRIX": json.dumps( + [ + {"language": "python", "build-mode": "none"}, + {"language": "javascript-typescript", "build-mode": "none"}, + ] + ), + "SUPPLIED_REQUIRED_JOBS": json.dumps( + [ + {"language": "javascript-typescript", "job_id": "55"}, + {"language": "python", "job_id": 43}, + ] + ), + }, _matching_pull_request(), ) - assert result.returncode == 1 - assert "matrix must contain exactly one valid language/build-mode shard" in result.stdout + assert result.returncode == 0, result.stderr + result.stdout + output_text = result.output_path.read_text(encoding="utf-8") + assert "javascript-typescript" in output_text + assert '"job_id":55' in output_text.replace(" ", "") + assert '"job_id":43' in output_text.replace(" ", "") def test_codeql_scan_dispatch_validate_step_rejects_stale_head_sha(tmp_path): @@ -347,8 +421,9 @@ def test_dispatch_wake_has_only_trusted_actions_write_boundary() -> None: assert "actions: write" in scan_permissions assert "pull_request:" not in workflow assert "pull_request_target:" not in workflow - assert "github.event.client_payload.required_run_id != ''" in scan - assert "github.event.client_payload.required_job_id != ''" in scan + assert "needs.validate-dispatch.outputs.required_run_id != ''" in scan + assert "needs.validate-dispatch.outputs.required_jobs != ''" in scan + assert "github.event.client_payload.required_job_id" not in scan def _run_wake_step( @@ -419,7 +494,12 @@ def _run_wake_step( "PR_NUMBER": "42", "HEAD_SHA": head_sha, "REQUIRED_RUN_ID": "42", - "REQUIRED_JOB_ID": "43", + "REQUIRED_JOBS": json.dumps( + [ + {"language": "python", "job_id": 43}, + {"language": "actions", "job_id": 44}, + ] + ), "REQUIRED_LANGUAGE": "python", } result = subprocess.run( diff --git a/tests/test_docs_only_pr_runner_admission.py b/tests/test_docs_only_pr_runner_admission.py index 8b2e6e8ee2..49631d2a19 100644 --- a/tests/test_docs_only_pr_runner_admission.py +++ b/tests/test_docs_only_pr_runner_admission.py @@ -186,13 +186,13 @@ def test_codeql_pr_gates_analyze_head_at_step_level_not_job_level(): the required `CodeQL compatibility analysis (actions|python)` contexts, so those required checks never appear. Gating the steps instead lets the job run (~20s), succeed, and publish the correctly expanded names. Since - the dispatch+poll rewrite (docs/adr/0025-codeql-required-workflow-dispatch-architecture.md), - `analyze-head` has two steps: the dispatch step's `if:` additionally - restricts it to the first matrix shard (see - tests/test_codeql_pr_workflow_contract.py::test_codeql_pr_dispatches_once_not_once_per_matrix_shard), - while the poll step runs unconditionally on `code == 'true'` alone -- both - still gate at step level, never at job level. `analyze-merge` no longer - exists: it was required nowhere (PR #1766) and was dropped, not migrated. + the dispatch+exact-job-wake rewrite + (docs/adr/0025-codeql-required-workflow-dispatch-architecture.md), + `analyze-head` has two steps: the verdict-read step and the runner-release + step. Both still gate at step level on `code == 'true'`, never at job + level. The one-shot coordinator that POSTs the remaining language matrix + is a separate job. `analyze-merge` no longer exists: it was required + nowhere (PR #1766) and was dropped, not migrated. """ workflow = _read("codeql-pr.yml") 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 dd7af43427..ba0b2598a9 100644 --- a/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py +++ b/tests/test_scheduler_and_codeql_dispatch_runner_image_contract.py @@ -45,10 +45,10 @@ def test_hourly_review_repair_uses_explicit_supported_image(self) -> None: self.assert_explicit_supported_image(HOURLY_REVIEW_REPAIR) def test_codeql_pr_uses_explicit_supported_image(self) -> None: - """Require both CodeQL PR compatibility-analysis jobs to pin Ubuntu 24.04.""" + """Require detect-languages, analyze-head, and the coordinator to pin Ubuntu 24.04.""" workflow = CODEQL_PR.read_text(encoding="utf-8") self.assertNotIn("runs-on: ubuntu-latest", workflow) - self.assertEqual(workflow.count("runs-on: ubuntu-24.04"), 2) + 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."""