diff --git a/.github/workflows/codeql-pr.yml b/.github/workflows/codeql-pr.yml index cb07ad2fab..b4ff2d4bb9 100644 --- a/.github/workflows/codeql-pr.yml +++ b/.github/workflows/codeql-pr.yml @@ -6,7 +6,10 @@ # 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. +# that exact failed job. On rerun the shard consumes an authenticated terminal +# status when one exists. If earlier attempts never reached dispatch and no +# authenticated verdict exists, the rerun may dispatch the same exact shard; +# the central target/PR/language concurrency lane bounds duplicate recovery. # Design: # docs/adr/0025-codeql-required-workflow-dispatch-architecture.md. The # merge-preview scan (analyze-merge) is required nowhere (PR #1766) and was @@ -158,9 +161,11 @@ jobs: 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. + # run, job, language, and head identity. The shard intentionally fails + # after dispatch so its runner is released; the trusted handler later + # reruns that exact failed job after publishing a terminal verdict. + # Rerun attempt count is not a dispatch receipt: earlier attempts can + # be cancelled before runner assignment and execute zero steps. id: dispatch if: needs.detect-languages.outputs.code == 'true' env: @@ -215,10 +220,6 @@ jobs: exit 0 ;; esac - if [ "$RUN_ATTEMPT" != "1" ]; then - 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." diff --git a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md index 065a9d4d0f..495f473d4d 100644 --- a/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md +++ b/docs/adr/0025-codeql-required-workflow-dispatch-architecture.md @@ -96,14 +96,18 @@ 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. Each invocation + first consumes a trusted terminal + codeql-dispatch/ status for the exact + current head when one exists. Otherwise it + validates the exact required run/job/language + identity, dispatches that one shard, records + verdict=pending, and fails intentionally to + release the runner. The trusted handler later + publishes the terminal status and reruns only + that failed job. A later run_attempt is not + treated as proof that any earlier attempt + reached the dispatch step. .github/workflows/codeql-scan-dispatch.yml (NEW, runs natively in .github, NOT admitted through the ruleset, so codeql-action is unrestricted here) @@ -170,6 +174,36 @@ 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. +### Rerun recovery is evidence-driven, not attempt-driven + +`github.run_attempt` is execution metadata. It is not an authenticated receipt +that a previous attempt reached `Request current-head CodeQL scan dispatch`. +The concrete counterexample is +`ContextualWisdomLab/accounting-information-platform#49@065f9ab7038bf35db4ef129827de6ab8ee6a1038`, +required CodeQL run `33890965185`: attempts 1 and 2 were cancelled before +runner assignment (`runner_id=0`, `steps=[]`). Attempt 3 finally ran, found no +trusted terminal `codeql-dispatch/actions` or `codeql-dispatch/python` status, +and the former `RUN_ATTEMPT != 1` guard rejected both shards before dispatch. +The unchanged consumer head was therefore unable to recover after capacity +returned. + +The required workflow must instead use authenticated evidence. For the exact +live PR head and language shard, a terminal status created by the expected +central identity is consumed. If no such terminal verdict exists, the shard +re-validates its run/job/head identity and may dispatch again regardless of the +numeric attempt. The central target/repository/PR/language concurrency key +bounds duplicate recovery; the handler independently re-validates live PR and +wake identity before it publishes a verdict or reruns the exact job. Missing +evidence remains fail closed: redispatch produces `verdict=pending`, never a +synthetic success. + +A manually requested rerun can arrive while an earlier native dispatch is still +queued but has not published a terminal status. In that case the existing +concurrency lane may replace work for the same exact logical shard. This is a +bounded restart risk, not a reason to restore attempt-number inference. If +observed churn becomes material, the successor design must add an authenticated +pending/dispatch-receipt state keyed to the same exact identity. + ## Scope decision: `analyze-merge` is dropped, not migrated `analyze-merge` ("CodeQL merge preview") is confirmed, per PR #1766's own @@ -230,6 +264,10 @@ blocker for this one. documented, evidently deliberate platform limitation ("CodeQL requires configuration at the repository level"), not a bug report candidate. +- **Use run-attempt number as a dispatch receipt:** rejected after the AIP #49 + reproduction. Earlier attempts can be cancelled before any step executes, + so an attempt number cannot prove that a native scan was requested. Only + authenticated exact-head status/receipt evidence may suppress redispatch. ## Risks and effects @@ -250,6 +288,10 @@ blocker for this one. assert zero matches, as a permanent contract test) — re-adding it with the bug still present would recreate the exact org-wide 100%-startup_failure incident this ADR exists to prevent. +- Rerun recovery can replace an already queued same-shard native dispatch when + no terminal status exists yet. The concurrency key keeps that restart within + the exact repository/PR/language identity. If this causes material churn, + add an authenticated pending receipt rather than trusting run-attempt order. ## Follow-up @@ -261,7 +303,14 @@ blocker for this one. ADR is the record). 4. Add a permanent contract test asserting no `codeql-action` reference exists anywhere in `codeql-pr.yml`. -5. Only then, re-add `.github/workflows/codeql-pr.yml` to ruleset `18156473`'s +5. Keep the rerun-recovery regression that executes the production dispatch + shell with a later `run_attempt`, no trusted terminal verdict, and exact + run/job/head identity; it must reach bounded dispatch with `verdict=pending`. +6. After protected integration, rerun the unchanged AIP #49 head and require + real authenticated terminal `codeql-dispatch/actions` and + `codeql-dispatch/python` verdicts before treating the owner repair as + effective for that consumer. +7. Only then, re-add `.github/workflows/codeql-pr.yml` to ruleset `18156473`'s required `workflows` list (admin:org PUT, same mechanism used to remove it) and verify a real PR observes a successful, correctly-named required check before declaring this ADR's status Accepted. 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..3a5952616b --- /dev/null +++ b/docs/doctoring/codeql-rerun-pre-runner-cancellation-recovery.md @@ -0,0 +1,60 @@ +# 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 `codeql-dispatch/` statuses created by the expected `opencode-agent` identity; +3. if an authenticated terminal status exists, reflect it without dispatching; +4. otherwise validate the exact required run/job identity, obtain the OIDC-bound app token, and dispatch the exact repository/PR/head/language shard. + +Remove the `RUN_ATTEMPT != 1` veto. A rerun attempt number is execution metadata, not evidence that the dispatch step ever ran. The native handler already serializes the same target-repository / pull-request / language tuple and re-validates live PR and wake identity before publishing a verdict or rerunning the exact required job. + +This does not convert a missing CodeQL verdict to success. The required shard still fails with `verdict=pending` after dispatch and becomes successful only when the trusted handler publishes an authenticated terminal `success` status and reruns the exact job. A forged status, stale head, failed/error verdict, unavailable OIDC/app token, malformed run/job identity, or absent dispatch receipt remains fail closed. + +## Executable regression + +`tests/test_codeql_pr_rerun_recovery_contract.py` executes the production `Request current-head CodeQL scan dispatch` Bash block with: + +- `RUN_ATTEMPT=3`; +- the same live target head; +- no authenticated CodeQL status; +- mocked OIDC and app-token exchange boundaries; and +- an exact run/job/language wake identity matching the accounting-platform reproduction. + +The test requires the step to publish `verdict=pending` and to emit a `codeql-scan` repository-dispatch payload bound to `ContextualWisdomLab/accounting-information-platform`, PR #49, the exact head, run `33890965185`, job `101220582747`, and `python`. + +Before the production change, the real shell block exits at the attempt-number guard before OIDC or dispatch, so this regression is RED for the observed reason. After the guard is removed, the same shell block reaches the bounded dispatch path. + +## Risks, rollback, and acceptance + +A manually requested rerun while a prior native dispatch is still queued but has not yet published a terminal status may replace work in the existing central target/PR/language concurrency lane. This is bounded to the same exact logical shard and does not broaden repository, head, language, credential, or merge authority. If live evidence shows harmful restart churn, the successor design should add an authenticated dispatch-receipt/pending state rather than restoring attempt-number inference. + +Rollback is not `RUN_ATTEMPT != 1`; that recreates the proven dead end. A valid replacement must distinguish “prior dispatch accepted” from “prior attempt never executed” using authenticated evidence and retain exact-head fail-closed semantics. + +GREEN requires all of the following on one unchanged successor head: + +- the focused rerun-recovery regression passes; +- 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 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..2551e57e68 --- /dev/null +++ b/tests/test_codeql_pr_rerun_recovery_contract.py @@ -0,0 +1,113 @@ +"""Regression for CodeQL reruns whose earlier attempts never reached dispatch.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +from tests.test_opencode_workflow_shell_syntax import _extract_run_block + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WORKFLOW_PATH = REPO_ROOT / ".github/workflows/codeql-pr.yml" +DISPATCH_STEP_NAME = "Request current-head CodeQL scan dispatch" + + +def test_rerun_without_authenticated_verdict_can_redispatch(tmp_path: Path) -> None: + """A later attempt may dispatch when earlier attempts never produced a verdict.""" + bash = shutil.which("bash") + jq = shutil.which("jq") + assert bash is not None and jq is not None + + workflow = WORKFLOW_PATH.read_text(encoding="utf-8") + script = _extract_run_block(workflow, DISPATCH_STEP_NAME) + head_sha = "b" * 40 + base_sha = "a" * 40 + + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + dispatch_body = tmp_path / "dispatch.json" + + fake_gh = fake_bin / "gh" + fake_gh.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'test "$1" = api\n' + "shift\n" + 'if [ "${1:-}" = "-X" ]; then\n' + ' test "$2" = POST\n' + ' test "$3" = "repos/ContextualWisdomLab/.github/dispatches"\n' + ' cat >"$FAKE_DISPATCH_BODY"\n' + " exit 0\n" + "fi\n" + 'case "$1" in\n' + " */pulls/*) printf '%s\\n' \"$FAKE_PULL_JSON\" ;;\n" + " */statuses) printf '%s\\n' '[]' ;;\n" + " *) exit 1 ;;\n" + "esac\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" + 'last="${@: -1}"\n' + 'case "$last" in\n' + " *audience=opencode-github-action) printf '%s\\n' '{\"value\":\"oidc-token\"}' ;;\n" + " */exchange_github_app_token) printf '%s\\n' '{\"token\":\"app-token\"}' ;;\n" + " *) exit 1 ;;\n" + "esac\n", + encoding="utf-8", + ) + fake_curl.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_DISPATCH_BODY": str(dispatch_body), + "GH_TOKEN": "leaf-token", + "OIDC_AUDIENCE": "opencode-github-action", + "OPENCODE_API_BASE_URL": "https://api.opencode.ai", + "TARGET_REPOSITORY": "ContextualWisdomLab/accounting-information-platform", + "PR_NUMBER": "49", + "PR_BASE_REF": "develop", + "PR_BASE_SHA": base_sha, + "PR_HEAD_REF": "fix/restore-accounting-doc-ci-evidence", + "PR_HEAD_SHA": head_sha, + "LANGUAGE": "python", + "BUILD_MODE": "none", + "RUN_ATTEMPT": "3", + "REQUIRED_RUN_ID": "33890965185", + "REQUIRED_JOB_ID": "101220582747", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.example/token", + "GITHUB_OUTPUT": str(output), + } + + result = subprocess.run( + [bash], + input=script, + text=True, + capture_output=True, + check=False, + env=env, + timeout=60, + ) + + assert result.returncode == 0, result.stderr + assert "verdict=pending" in output.read_text(encoding="utf-8") + payload = json.loads(dispatch_body.read_text(encoding="utf-8")) + assert payload["event_type"] == "codeql-scan" + client_payload = payload["client_payload"] + assert client_payload["target_repository"] == "ContextualWisdomLab/accounting-information-platform" + assert client_payload["pr_head_sha"] == head_sha + assert client_payload["required_run_id"] == "33890965185" + assert client_payload["required_job_id"] == "101220582747" + assert client_payload["required_language"] == "python"