From 5afdfcaac9d10e1f216cb3e7ec7df9c8971c8432 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 22:37:22 +0200 Subject: [PATCH 1/4] ci(codeql): run CodeQL on main, dry run and release, not per PR CodeQL ran on every pull_request push, and PR CI's security / codeql-gate job polled up to 150 min for that run before ci-ok could pass. With ~30 PRs pushed at once, the gate jobs held runners while the CodeQL runs they waited for queued behind them, and PR CI starved for hours. - codeql.yml: triggers are push to main, a weekly schedule, workflow_call and workflow_dispatch; pull_request is gone. The open-alert check moves here, unchanged and still fail-closed, as the `alerts` job (needs analyze), so every main push and every weekly run fails while any code-scanning alert is open. Push runs keep cancel-in-progress; called and scheduled runs get a group of their own. The analysis key is pinned to the push configuration so dry-run/release uploads do not open configurations of their own on main. - _security.yml: codeql-gate removed; callers grant contents: read only. - dry-run.yml, release.yml: a `codeql` job calls codeql.yml on the dispatched commit; release verify needs it and requires success. - select-lanes.sh, require-all-green.sh: the codeql-gate lane is gone from the selector universe and from ci-ok's job map; no other lane changes. - tests: the fail-closed contract follows the check to codeql.yml and now also pins the triggers, the derived callers' grants and release verify; lane fixtures drop codeql-gate. - SECURITY.md, MAINTAINERS.md: say where CodeQL runs now. Signed-off-by: Martin Vogel --- .github/workflows/_lint.yml | 2 +- .github/workflows/_security.yml | 97 +------------------ .github/workflows/codeql.yml | 114 ++++++++++++++++++++-- .github/workflows/dry-run.yml | 24 +++-- .github/workflows/pr.yml | 7 +- .github/workflows/release.yml | 31 ++++-- MAINTAINERS.md | 2 +- SECURITY.md | 2 +- scripts/ci/require-all-green.sh | 2 +- scripts/ci/select-lanes.sh | 6 +- tests/fixtures/select-lanes/cases.tsv | 14 +-- tests/test_lane_gate_contract.sh | 1 - tests/test_lane_wiring_contract.sh | 1 - tests/test_security_gate_fail_closed.sh | 123 ++++++++++++++---------- tests/test_select_lanes.sh | 2 +- 15 files changed, 236 insertions(+), 192 deletions(-) diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 6fcda15e4..741e3c438 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -1,5 +1,5 @@ # Reusable: lint only (cppcheck + clang-format). -# Security-static and CodeQL gate are separate — see _security.yml. +# Security-static is separate — see _security.yml; CodeQL — see codeql.yml. name: Lint & Security on: diff --git a/.github/workflows/_security.yml b/.github/workflows/_security.yml index 666573471..40514091a 100644 --- a/.github/workflows/_security.yml +++ b/.github/workflows/_security.yml @@ -1,4 +1,6 @@ -# Reusable: security-static + CodeQL gate. +# Reusable: security-static + license gate. +# CodeQL is not here: PRs do not run it. It runs on push to main, weekly, and +# inside dry-run.yml / release.yml, which call codeql.yml directly. # Runs independently from lint/test/build — does not block the main pipeline. # Affects overall workflow success status. name: Security Gate @@ -52,96 +54,3 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: scripts/audit-license-provenance.py - codeql-gate: - if: ${{ inputs.lanes == 'all' || contains(fromJSON(inputs.lanes), 'codeql-gate') }} - runs-on: ubuntu-latest - timeout-minutes: 240 - # Without security-events:read this job inherits the workflow's - # contents:read, the code-scanning alert API answers 403, and the gate - # below reported "0 alerts" on every run it has ever made. actions:read is - # what the CodeQL-run poll above needs for the same reason. - permissions: - contents: read - security-events: read - actions: read - steps: - - name: Wait for CodeQL on current commit (max 150 min) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # On pull_request events github.sha is the synthetic merge commit; - # CodeQL runs are recorded against the PR head SHA. - CURRENT_SHA="${{ github.event.pull_request.head.sha || github.sha }}" - echo "Waiting for CodeQL to complete on $CURRENT_SHA..." - # 300 attempts x 30s = 150 min. The previous budget was 90 x 30s = 45 min, - # which is shorter than CodeQL actually takes on this repository: a measured - # run on PR #1426 (head 7b72652a) completed with conclusion=success after - # 124 min, 7 minutes AFTER this gate had already given up. That marked at - # least five contributor PRs red for a scan that passed. The job's own - # timeout-minutes is 240, so 150 still leaves headroom. - for attempt in $(seq 1 300); do - LATEST=$(gh api "repos/${{ github.repository }}/actions/workflows/codeql.yml/runs?head_sha=$CURRENT_SHA&per_page=1" \ - --jq '.workflow_runs[] | "\(.conclusion) \(.status)"' 2>/dev/null | head -1 || echo "") - if [ -z "$LATEST" ]; then - echo " $attempt/300: no run yet..."; sleep 30; continue - fi - CONCLUSION=$(echo "$LATEST" | cut -d' ' -f1) - STATUS=$(echo "$LATEST" | cut -d' ' -f2) - if [ "$STATUS" = "completed" ] && [ "$CONCLUSION" = "success" ]; then - echo "=== CodeQL passed ==="; exit 0 - elif [ "$STATUS" = "completed" ]; then - echo "BLOCKED: CodeQL $CONCLUSION"; exit 1 - fi - echo " $attempt/300: $STATUS..."; sleep 30 - done - echo "BLOCKED: CodeQL timeout"; exit 1 - - - name: Check for open code scanning alerts - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`, - # which looks defensive and is the opposite. gh writes the API error - # BODY to stdout, so a 403 made ALERTS the string - # {"message":"Resource not accessible by integration",...,"status":"403"}0 - # every `[` comparison then failed as non-integer, the `if` took its - # false branch, and the step printed "CodeQL gate passed (0 alerts)" - # and exited 0. That is exactly how a security gate should NEVER fail: - # silently, in the direction of "everything is fine". An unreadable - # alert list is now an error, not a zero. - # NOTE: this runs in a command substitution, so it must `return`, not - # `exit` -- an `exit` here would only end the subshell and let the - # caller continue with an empty count, recreating the very bug this - # replaces. The callers below check the status explicitly. - count_open_alerts() { - local body status - body=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ - --jq 'length' 2>/dev/null) - status=$? - if [ $status -ne 0 ]; then - echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2 - echo " Refusing to treat an unreadable alert list as zero." >&2 - echo " Most likely cause: the job lacks security-events:read." >&2 - return 1 - fi - case "$body" in - ''|*[!0-9]*) - echo "BLOCKED: alert count was not a number: ${body:-}" >&2 - return 1 - ;; - esac - printf '%s' "$body" - } - echo "Waiting 60s for alert API to settle..." - sleep 60 - ALERTS=$(count_open_alerts) || exit 1 - sleep 15 - ALERTS2=$(count_open_alerts) || exit 1 - [ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2 - if [ "$ALERTS" -gt 0 ]; then - echo "BLOCKED: $ALERTS open alert(s)" - gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ - --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true - exit 1 - fi - echo "=== CodeQL gate passed (0 alerts) ===" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 621d407fc..b44368422 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,31 +1,55 @@ name: CodeQL SAST +# CodeQL runs on main, on a schedule and inside the dry run and the release -- +# never per pull request. A per-PR analysis (40-120 min each) plus the PR-CI +# job that waited up to 150 min for it held runners that the PR legs needed: +# with ~30 PRs pushed at once, PR CI starved for hours. +# +# push to main every merge is analysed; the `alerts` job fails the run +# while any code-scanning alert is open (alert policy). +# schedule weekly, so a CodeQL query update that finds something in +# unchanged code is noticed without a push. +# workflow_call dry-run.yml and release.yml analyse the exact commit they +# test; their `codeql` job (analyze + alerts) must pass. +# workflow_dispatch a manual re-run on any ref. on: push: branches: [main] - pull_request: - branches: [main] + schedule: + - cron: "23 5 * * 1" + workflow_dispatch: + workflow_call: permissions: contents: read -# One analysis per pull request and one per pushed branch. A newer push to main -# cancels the analysis of an older main commit; only the newest commit's -# results matter for main, and a merge-heavy day otherwise queues one 40-100 -# minute analysis per merge ahead of every pull request run. -concurrency: - group: codeql-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - jobs: analyze: runs-on: ubuntu-latest + # A newer push to main cancels the analysis of an older main commit; only + # the newest commit's results matter for main. Every other trigger gets a + # group of its own and is never cancelled, so a dry run and a release + # dispatched together cannot cancel each other's gate. In a called workflow + # `github` is the CALLER's context: github.workflow names the dry run or + # the release, github.run_id is the caller's run. + concurrency: + group: codeql-${{ github.event_name == 'push' && github.ref || format('{0}-{1}', github.workflow, github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'push' }} # CodeQL needs security-events: write to upload results; scoped to this job so # the workflow's top-level token stays read-only (Scorecard TokenPermissions). permissions: security-events: write actions: read contents: read + env: + # One code-scanning configuration for every trigger. The action derives + # the key from the RUN's workflow file, which for a called workflow is + # the caller: dry-run.yml / release.yml would each open a configuration + # of their own on main, and an alert fixed by a later push would stay + # open in the stale one until the next dry run. The value is the key the + # push-to-main analysis has always used (the action documents this + # variable as settable for non-standard setups). + CODEQL_ACTION_ANALYSIS_KEY: .github/workflows/codeql.yml:analyze steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -45,3 +69,73 @@ jobs: uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: category: "/language:c-cpp" + + # Alert policy: the default branch carries ZERO open code-scanning alerts. + # Any open alert -- a new finding from a merge, from the weekly run, or one + # this commit adds -- fails this job: the main commit turns red, GitHub + # notifies whoever triggered the run, and the dry run and the release (which + # call this workflow) cannot pass until the alert is fixed or dismissed with + # a reason in the Security tab. With a zero baseline, "any open alert" is + # exactly "a new alert". Moved here unchanged from _security.yml's former + # PR-CI codeql-gate job; it reads the default branch's alerts, as it did there. + alerts: + needs: [analyze] + runs-on: ubuntu-latest + timeout-minutes: 10 + # Without security-events:read this job inherits the workflow's + # contents:read, the code-scanning alert API answers 403, and the former + # gate reported "0 alerts" on every run it ever made. + permissions: + contents: read + security-events: read + steps: + - name: Check for open code scanning alerts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`, + # which looks defensive and is the opposite. gh writes the API error + # BODY to stdout, so a 403 made ALERTS the string + # {"message":"Resource not accessible by integration",...,"status":"403"}0 + # every `[` comparison then failed as non-integer, the `if` took its + # false branch, and the step printed "CodeQL gate passed (0 alerts)" + # and exited 0. That is exactly how a security gate should NEVER fail: + # silently, in the direction of "everything is fine". An unreadable + # alert list is now an error, not a zero. + # NOTE: this runs in a command substitution, so it must `return`, not + # `exit` -- an `exit` here would only end the subshell and let the + # caller continue with an empty count, recreating the very bug this + # replaces. The callers below check the status explicitly. + count_open_alerts() { + local body status + body=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ + --jq 'length' 2>/dev/null) + status=$? + if [ $status -ne 0 ]; then + echo "BLOCKED: cannot read code scanning alerts (gh exit $status)." >&2 + echo " Refusing to treat an unreadable alert list as zero." >&2 + echo " Most likely cause: the job lacks security-events:read." >&2 + return 1 + fi + case "$body" in + ''|*[!0-9]*) + echo "BLOCKED: alert count was not a number: ${body:-}" >&2 + return 1 + ;; + esac + printf '%s' "$body" + } + echo "Waiting 60s for alert API to settle..." + sleep 60 + ALERTS=$(count_open_alerts) || exit 1 + sleep 15 + ALERTS2=$(count_open_alerts) || exit 1 + [ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2 + if [ "$ALERTS" -gt 0 ]; then + echo "::error title=Open code scanning alerts::$ALERTS open alert(s) on the default branch -- fix, or dismiss with a reason in the Security tab" + echo "BLOCKED: $ALERTS open alert(s)" + gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ + --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true + exit 1 + fi + echo "=== CodeQL gate passed (0 alerts) ===" diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 600dbad04..3af53e094 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -2,9 +2,10 @@ # Each step can be skipped for faster iteration. # # Pipeline: lint → test → build + candidate scan/select → smoke/soak -# Security: security-static + codeql-gate (independent island) +# Security: security-static + license-gate (independent island) +# CodeQL: codeql.yml on this commit (analyze + alerts) (independent island) # -# Security does NOT block lint/test/build/smoke/soak. All jobs must pass +# Security and CodeQL do NOT block lint/test/build/smoke/soak. All jobs must pass # for the overall workflow to be green. # # Candidate VirusTotal scanning and tuple-local selection are part of the @@ -62,15 +63,24 @@ jobs: # ── Security (independent island — does not block main pipeline) ── security: uses: ./.github/workflows/_security.yml - # The called job requests security-events:read to count code-scanning - # alerts; a reusable workflow cannot request more than its caller grants, - # so withholding this here fails the whole run at startup. + # No job in _security.yml reads code scanning or the Actions API any more + # (the CodeQL gate moved to codeql.yml), so the token stays read-only. permissions: contents: read - security-events: read - actions: read secrets: inherit + # ── CodeQL: analysis of this exact commit + the open-alert check ── + # PRs do not run CodeQL; the dry run and the release are where a CodeQL pass + # is required. codeql.yml's analyze job uploads results and its alerts job + # reads them: a called workflow cannot request more than its caller grants, + # so withholding either permission fails the whole run at startup. + codeql: + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + actions: read + # ── Lint (cppcheck + clang-format) ──────────────────────────── lint: if: ${{ inputs.skip_lint != true }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 815146253..7df1a2f93 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -26,13 +26,10 @@ jobs: security: needs: [changes] uses: ./.github/workflows/_security.yml - # The called job requests security-events:read to count code-scanning - # alerts; a reusable workflow cannot request more than its caller grants, - # so withholding this here fails the whole run at startup. + # No job in _security.yml reads code scanning or the Actions API any more + # (the CodeQL gate moved to codeql.yml), so the token stays read-only. permissions: contents: read - security-events: read - actions: read secrets: inherit with: lanes: ${{ needs.changes.outputs.lanes }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 134b425e2..9503f966d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ # Release pipeline: lint → test → build → smoke/soak → draft → verify → publish # -# Security (security-static + codeql-gate) starts immediately and runs in -# parallel with lint/test/build/smoke/soak. It only blocks the final verify +# Security (security-static + license-gate) and CodeQL (codeql.yml: analysis +# of this commit + the open-alert check) start immediately and run in +# parallel with lint/test/build/smoke/soak. They only block the final verify # step — everything else proceeds independently. name: Release @@ -44,15 +45,25 @@ jobs: # ── Security (starts immediately, blocks only verify) ─────────── security: uses: ./.github/workflows/_security.yml - # The called job requests security-events:read to count code-scanning - # alerts; a reusable workflow cannot request more than its caller grants, - # so withholding this here fails the whole run at startup. + # No job in _security.yml reads code scanning or the Actions API any more + # (the CodeQL gate moved to codeql.yml), so the token stays read-only. permissions: contents: read - security-events: read - actions: read secrets: inherit + # ── CodeQL (starts immediately, blocks only verify) ────────────── + # The analysis of this exact commit + the open-alert check. + # PRs do not run CodeQL; the dry run and the release are where a CodeQL pass + # is required. codeql.yml's analyze job uploads results and its alerts job + # reads them: a called workflow cannot request more than its caller grants, + # so withholding either permission fails the whole run at startup. + codeql: + uses: ./.github/workflows/codeql.yml + permissions: + contents: read + security-events: write + actions: read + # ── 0. Preflight: refuse malformed dispatch inputs ────────────── # The tag is inputs.version VERBATIM. A bare "0.10.7" publishes a release the # installers can never resolve (they fetch releases/download/v/...), @@ -312,12 +323,12 @@ jobs: body: ${{ inputs.release_notes || '' }} generate_release_notes: ${{ inputs.release_notes == '' }} - # ── 7. Verify + Publish (requires security gate) ─────────────── + # ── 7. Verify + Publish (requires the security and CodeQL gates) ─ verify: - needs: [release-draft, security] + needs: [release-draft, security, codeql] # Explicit condition: a deliberately skipped test ancestor must not skip # the publish chain; the draft itself proves smoke and soak both succeeded. - if: ${{ !cancelled() && !failure() && needs.security.result == 'success' && needs.release-draft.result == 'success' }} + if: ${{ !cancelled() && !failure() && needs.security.result == 'success' && needs.codeql.result == 'success' && needs.release-draft.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write diff --git a/MAINTAINERS.md b/MAINTAINERS.md index d749762b6..2c52591a0 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -52,7 +52,7 @@ Operational authority is stricter than code review authority. | Operation | Current authority | Project rule | | --- | --- | --- | -| PR validation (`pr.yml`, DCO, CodeQL) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. | +| PR validation (`pr.yml`, DCO) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. | | Dry run (`dry-run.yml`) | `@DeusData` | Delegated release operators may run dry runs after promotion. Dry-run delegation does not imply release authority. | | Smoke/soak/repro manual runs | `@DeusData` | Area reviewers may operate these runs when delegated for diagnosis. Results are advisory. | | Release workflow (`release.yml`) | `@DeusData` only | Owner-only until a release operator is explicitly promoted. Publishing, replacing releases, and tag movement remain owner-gated. | diff --git a/SECURITY.md b/SECURITY.md index 48da1d1a1..bbf82af2e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -111,7 +111,7 @@ This project implements multiple layers of security verification. Every release - **All dangerous function calls** require a reviewed entry in `scripts/security-allowlist.txt` - **Time-bomb pattern detection** — scans for `time()`/`sleep()` near dangerous calls (could indicate delayed activation) - **MCP tool handler file read audit** — tracks file read count in `mcp.c` against an expected maximum (detects added file reads that could exfiltrate data through tool responses) -- **CodeQL SAST** — static application security testing on every push (taint analysis, CWE detection, data flow tracking). Any open alert blocks the release. +- **CodeQL SAST** — static application security testing on every push to main, weekly, and in every dry run and release (taint analysis, CWE detection, data flow tracking). Any open alert fails the main run and blocks the release. - **Fuzz testing** — random/mutated inputs to MCP server and Cypher parser (60 seconds per build). Catches crashes, segfaults, and memory errors that structured tests miss. - **Native antivirus scanning** on every platform (any detection fails the build): - **Windows**: Windows Defender with ML heuristics — the same engine end users run diff --git a/scripts/ci/require-all-green.sh b/scripts/ci/require-all-green.sh index 027118287..c1d916e49 100755 --- a/scripts/ci/require-all-green.sh +++ b/scripts/ci/require-all-green.sh @@ -66,7 +66,7 @@ if 'LANES' in os.environ: single = { 'lint / lint': 'lint', 'lint / lint-mem': 'lint-mem', 'security / security-static': 'security-static', - 'security / license-gate': 'license-gate', 'security / codeql-gate': 'codeql-gate', + 'security / license-gate': 'license-gate', 'test / test-diag': 'diag', 'test / test-msan': 'msan', 'test / test-lsan-macos': 'lsan-macos', 'test / test-windows-guards': 'windows-guards', 'test / shard-completeness': 'shard-completeness', 'contracts': 'contracts', diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index aaabdb107..56a26fc67 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -293,7 +293,7 @@ def tier_of(cats): # ── Tier -> lanes (R1) ── -FULL = {"codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", +FULL = {"diag", "license-gate", "lint", "lint-mem", "lsan-macos", "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", @@ -308,8 +308,8 @@ R1 = { # The contract steps police test-infrastructure/ and lint config too. "T0b-nonproduct": LINT | {"contracts"}, "T1-tests": LINT | CORE_LEGS, - "T2-extract": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, - "T3-mid": LINT | CORE_LEGS | {"codeql-gate", "tsan-x86", "smoke-ubuntu"}, + "T2-extract": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, + "T3-mid": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, "T4-platform": FULL - {"msan", "diag", "memwaste"}, "T5-core": FULL, } diff --git a/tests/fixtures/select-lanes/cases.tsv b/tests/fixtures/select-lanes/cases.tsv index 54a8404c2..01996dc22 100644 --- a/tests/fixtures/select-lanes/cases.tsv +++ b/tests/fixtures/select-lanes/cases.tsv @@ -19,13 +19,13 @@ test-fixture tests/fixtures/cpp_include/a.h T1-tests false lint lint-mem securit contract-test tests/test_version_metadata_contract.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows fuzz-harness tests/fuzz/fuzz_cypher.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows docs-plus-tests README.md tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows -lsp-language internal/cbm/lsp/go_lsp.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -lsp-core internal/cbm/lsp/type_registry.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -extraction internal/cbm/extract_calls.c T2-extract false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -pipeline src/pipeline/pass_calls.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false codeql-gate lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false codeql-gate lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +lsp-language internal/cbm/lsp/go_lsp.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +lsp-core internal/cbm/lsp/type_registry.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +extraction internal/cbm/extract_calls.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +pipeline src/pipeline/pass_calls.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows +mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste diff --git a/tests/test_lane_gate_contract.sh b/tests/test_lane_gate_contract.sh index da70d43e1..57e2cc12e 100755 --- a/tests/test_lane_gate_contract.sh +++ b/tests/test_lane_gate_contract.sh @@ -45,7 +45,6 @@ t1_jobs() { # the jobs of a green T1 run; $1 = conclusion of the x86 leg job "changes" success job "security / security-static" success job "security / license-gate" skipped - job "security / codeql-gate" skipped job "lint / lint" success job "lint / lint-mem" success job "test / setup-matrix" success diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index d79579725..a2a73511f 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -159,7 +159,6 @@ MATRIX = {"unix-x86": "_test.yml", "unix-arm64": "_test.yml", "unix-macos14": "_ "smoke-mac": "pr.yml", "smoke-win": "pr.yml"} SINGLE = {"lint": ("_lint.yml", "lint"), "lint-mem": ("_lint.yml", "lint-mem"), "license-gate": ("_security.yml", "license-gate"), - "codeql-gate": ("_security.yml", "codeql-gate"), "pkg-wrappers": ("_test.yml", "test-package-wrappers"), "diag": ("_test.yml", "test-diag"), "msan": ("_test.yml", "test-msan"), "lsan-macos": ("_test.yml", "test-lsan-macos"), diff --git a/tests/test_security_gate_fail_closed.sh b/tests/test_security_gate_fail_closed.sh index 0e79f2356..73eeaa71d 100644 --- a/tests/test_security_gate_fail_closed.sh +++ b/tests/test_security_gate_fail_closed.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # The code-scanning gate must FAIL CLOSED. # -# History. `codeql-gate` in .github/workflows/_security.yml counted open alerts -# with: +# History. `codeql-gate` in .github/workflows/_security.yml (since moved, as +# the `alerts` job, to .github/workflows/codeql.yml) counted open alerts with: # # ALERTS=$(gh api '.../code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") # @@ -20,7 +20,9 @@ # # A security gate that cannot read its input must stop the build, not wave it # through. This test pins both halves of that: the permission that makes the -# read possible, and the fail-closed handling for when it still is not. +# read possible, and the fail-closed handling for when it still is not. It also +# pins where the gate runs: never in PR CI (it starved PR CI of runners), on +# push to main, and inside the dry run and the release, whose verify needs it. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -41,24 +43,36 @@ def require(condition: bool, message: str) -> None: failures.append(message) -workflow = (root / ".github" / "workflows" / "_security.yml").read_text(encoding="utf-8") +workflow_dir = root / ".github" / "workflows" +workflow = (workflow_dir / "codeql.yml").read_text(encoding="utf-8") -# Isolate the codeql-gate job so a permission granted to some OTHER job cannot -# satisfy this test. -gate = re.search(r"\n codeql-gate:\n(?P(?: .*\n|\n)*)", workflow) -require(gate is not None, "_security.yml no longer defines a codeql-gate job") -body = gate.group("body") if gate else "" + +def job_body(source: str, job: str) -> str | None: + m = re.search(rf"\n {re.escape(job)}:\n(?P(?: .*\n|\n)*)", source) + return m.group("body") if m else None + + +# Isolate the alerts job so a permission granted to some OTHER job cannot +# satisfy this test. (It was _security.yml's codeql-gate job until CodeQL +# left PR CI; the logic moved unchanged.) +gate = job_body(workflow, "alerts") +require(gate is not None, "codeql.yml no longer defines an alerts job") +body = gate or "" +require( + re.search(r"^ needs: \[analyze\]\s*$", body, re.M) is not None, + "codeql.yml alerts must need analyze -- it counts the alerts that analysis uploaded", +) # 1. The read has to be possible at all. require( re.search(r"^ permissions:\s*$", body, re.M) is not None, - "codeql-gate must declare its own permissions block -- inheriting the " + "alerts must declare its own permissions block -- inheriting the " "workflow default leaves it without security-events access and the alert " "API answers 403", ) require( re.search(r"^ security-events:\s*read\s*$", body, re.M) is not None, - "codeql-gate must request security-events: read, or the code-scanning " + "alerts must request security-events: read, or the code-scanning " "alert query 403s and the gate cannot see any alert", ) @@ -71,11 +85,11 @@ require( ) require( "BLOCKED: cannot read code scanning alerts" in body, - "codeql-gate must fail with a named error when the alert API cannot be read", + "alerts must fail with a named error when the alert API cannot be read", ) require( re.search(r"alert count was not a number", body) is not None, - "codeql-gate must reject a non-numeric alert count instead of comparing it", + "alerts must reject a non-numeric alert count instead of comparing it", ) # 3. The count is produced in a command substitution, so a bare `exit` inside @@ -92,53 +106,64 @@ require( "inside a command substitution does not stop the caller on its own", ) -# 4. Every caller must GRANT what the job requests. A reusable workflow cannot -# request more permission than its caller holds: asking for -# security-events:read from a caller that grants only contents:read does not +# 4. Where CodeQL runs. Never per pull request (a per-PR analysis plus a PR-CI +# job waiting up to 150 min for it starved PR CI of runners); on push to +# main, and callable so the dry run and the release can require it. +on = re.search(r"(?ms)^on:\n(?P.*?)(?=^\S)", workflow) +triggers = set(re.findall(r"(?m)^ ([a-z_]+):", on.group("body"))) if on else set() +require("pull_request" not in triggers and "pull_request_target" not in triggers, + "codeql.yml must not run per pull request") +require({"push", "workflow_call"} <= triggers, + f"codeql.yml must run on push to main and be callable, has {sorted(triggers)}") +require(re.search(r"(?m)^ push:\n branches: \[main\]$", workflow) is not None, + "codeql.yml's push trigger must be main") +for name in ("pr.yml", "_security.yml"): + code = "\n".join( + line for line in (workflow_dir / name).read_text(encoding="utf-8").splitlines() + if not line.lstrip().startswith("#") + ) + require("codeql" not in code.lower(), + f"{name} must neither run nor wait for CodeQL (PR CI)") + +# 5. Every caller must GRANT what codeql.yml's jobs request. A reusable +# workflow cannot request more permission than its caller holds: asking for +# security-events from a caller that grants only contents:read does not # downgrade, it fails the entire run with a startup_failure before a single -# job executes. Checking only the called workflow is how this test passed -# while the PR pipeline never started. -# The caller list is DERIVED, never hardcoded. An earlier revision listed -# ("pr.yml", "release.yml") by hand and stayed green while dry-run.yml -- a -# third caller nobody had listed -- failed every run at startup. A test that -# only checks the callers you remembered cannot catch the one you forgot. -workflow_dir = root / ".github" / "workflows" +# job executes. The caller list is DERIVED, never hardcoded: an earlier +# revision listed callers by hand and stayed green while a third caller +# nobody had listed failed every run at startup. callers = sorted( path.name for path in workflow_dir.glob("*.yml") - if "uses: ./.github/workflows/_security.yml" in path.read_text(encoding="utf-8") + if "uses: ./.github/workflows/codeql.yml" in path.read_text(encoding="utf-8") ) require( - len(callers) >= 3, - "expected at least 3 callers of _security.yml, found: " - + (", ".join(callers) or "none") - + " -- if a caller was deliberately removed, lower this floor on purpose", + {"dry-run.yml", "release.yml"} <= set(callers), + "the dry run and the release must call codeql.yml (their CodeQL pass), callers: " + + (", ".join(callers) or "none"), ) - for caller_name in callers: caller = (workflow_dir / caller_name).read_text(encoding="utf-8") - # The next sibling may be a comment line, not a key, so the lookahead has to - # accept any 2-space-indented non-space -- matching only ` \w` silently - # failed to find release.yml's job at all. - call = re.search( - r"\n security:\n(?P(?: .*\n|\n)*?)(?=\n? \S|\Z)", caller - ) - require(call is not None, f"{caller_name} no longer has a security: job") - call_body = call.group("body") if call else "" + call_body = job_body(caller, "codeql") or "" require( - "uses: ./.github/workflows/_security.yml" in call_body, - f"{caller_name}'s security job must call _security.yml", - ) - require( - re.search(r"^ security-events:\s*read\s*$", call_body, re.M) is not None, - f"{caller_name} must grant security-events: read to the security job -- " - "a called workflow cannot request more than its caller grants, and the " - "run fails at startup rather than degrading", - ) - require( - re.search(r"^ actions:\s*read\s*$", call_body, re.M) is not None, - f"{caller_name} must grant actions: read to the security job", + "uses: ./.github/workflows/codeql.yml" in call_body, + f"{caller_name} must call codeql.yml from a job named codeql", ) + for perm, level in (("security-events", "write"), ("actions", "read"), ("contents", "read")): + require( + re.search(rf"^ {perm}:\s*{level}\s*$", call_body, re.M) is not None, + f"{caller_name} must grant {perm}: {level} to the codeql job -- a " + "called workflow cannot request more than its caller grants, and the " + "run fails at startup rather than degrading", + ) + +# 6. The release publishes nothing without the CodeQL pass. +release = (workflow_dir / "release.yml").read_text(encoding="utf-8") +verify = job_body(release, "verify") or "" +require(re.search(r"(?m)^ needs: \[[^\]]*\bcodeql\b[^\]]*\]$", verify) is not None, + "release.yml verify must need the codeql job") +require("needs.codeql.result == 'success'" in verify, + "release.yml verify must require needs.codeql.result == 'success'") if failures: print("Security gate fail-closed contract FAILED:") diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index a316630ce..095fcdd13 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -47,7 +47,7 @@ failures = [] # Every lane of the full PR pipeline, spelled out here rather than read from # the selector, so a lane silently dropped from the selector's universe fails. FULL = { - "codeql-gate", "diag", "license-gate", "lint", "lint-mem", "lsan-macos", + "diag", "license-gate", "lint", "lint-mem", "lsan-macos", "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", From 453b21e68086c3403aa64c2c8d17764324a87344 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 23:09:09 +0200 Subject: [PATCH 2/4] ci(codeql): PR scan per language inside PR CI, full scan on main Bring CodeQL back to pull requests as jobs of PR CI itself (needed by ci-ok, no polling), narrowed to first-party code, while push to main, the weekly run, the dry run and the release keep the full scan. - codeql.yml: one leg per language (c-cpp, javascript-typescript, python, actions), scope in .github/codeql/codeql-config.yml. The full scan traces the whole build, vendored C included. The PR scan (light: true) analyses each selected language's whole first-party scope; c-cpp compiles the third-party objects before tracing (scripts/build.sh --vendored-only) and then traces the first-party build plus the vendored files the PR changes (--reuse-vendored --retrace-from, make -W). Diff-informed analysis (codeql-action v4.38.1, forced on for PR calls) only restricts reported alerts. The alert check moves into each leg and reads the analysed ref. Push and PR runs cancel superseded runs per language; dispatched runs never cancel. Compiler cache off so every compile is traced. - pr.yml: a codeql job runs the legs of the selected codeql-* lanes; ci-ok needs it. - select-lanes.sh: codeql-cpp/-js/-py/-actions lanes, selected by a change in their scope (any C/C++ incl. vendored; JS/TS, Python and workflows inside the config's paths minus paths-ignore) and by every full run (undeterminable diff, workflows, build, CodeQL machinery). --codeql-scope LANGUAGE prints a list's in-scope paths. - codeql-retrace-vendored.sh: the vendored C files a PR changes (empty, never narrowing, when the diff cannot be determined). - Makefile.cbm: cbm-third-party builds the vendored prod objects only. - tests: the gate contract pins the new layout; the selector contract pins that a C change always selects the C scan, an undeterminable diff selects every language, and a vendored-only change retraces exactly the changed vendored files. Selector fixtures, ci-ok's job map, the replay's codeql-action failures (lane codeql-cpp) and the interface probes follow. Signed-off-by: Martin Vogel --- .github/codeql/codeql-config.yml | 27 +++ .github/workflows/codeql.yml | 157 ++++++++----- .github/workflows/dry-run.yml | 12 +- .github/workflows/pr.yml | 28 ++- .github/workflows/release.yml | 11 +- MAINTAINERS.md | 2 +- Makefile.cbm | 10 + SECURITY.md | 2 +- scripts/build.sh | 60 ++++- scripts/ci/codeql-retrace-vendored.sh | 89 ++++++++ scripts/ci/require-all-green.sh | 3 +- scripts/ci/select-lanes.sh | 114 +++++++++- .../fixtures/2026-09-real-failures.tsv | 8 +- tests/fixtures/select-lanes/cases.tsv | 36 +-- tests/test_lane_wiring_contract.sh | 3 +- tests/test_security_gate_fail_closed.sh | 206 +++++++++++++----- tests/test_select_lanes.sh | 82 +++++++ tests/test_venue_parity_contract.sh | 2 + 18 files changed, 694 insertions(+), 158 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 scripts/ci/codeql-retrace-vendored.sh diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..5c2a1ea68 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,27 @@ +# CodeQL scope: the repository's first-party source. +# +# Read by .github/workflows/codeql.yml (every scan, full and PR) and by +# scripts/ci/select-lanes.sh (which changed paths select which language's PR +# scan). Keep the lists plain: one path prefix per line, no globs. +# +# paths / paths-ignore narrow what the INTERPRETED languages extract +# (javascript-typescript, python, actions). For c-cpp they have no effect: +# CodeQL extracts exactly what the traced build compiles. A PR's c-cpp scan +# keeps vendored C out by compiling it before tracing starts (except vendored +# files the PR changes); the full scan on main, the dry run and the release +# traces everything, vendored C included. +name: cbm first-party source +paths: + - src + - internal + - graph-ui + - pkg + - scripts + - tests + - .github +paths-ignore: + - internal/cbm/vendored + - graph-ui/vendor + - graph-ui/fixtures + - graph-ui/verification + - tests/fixtures diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b44368422..f5cfb59d6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,24 +1,43 @@ name: CodeQL SAST -# CodeQL runs on main, on a schedule and inside the dry run and the release -- -# never per pull request. A per-PR analysis (40-120 min each) plus the PR-CI -# job that waited up to 150 min for it held runners that the PR legs needed: -# with ~30 PRs pushed at once, PR CI starved for hours. +# One analysis per language: c-cpp, javascript-typescript (graph-ui, pkg/npm), +# python, actions. Scope: .github/codeql/codeql-config.yml (first-party source). # -# push to main every merge is analysed; the `alerts` job fails the run -# while any code-scanning alert is open (alert policy). -# schedule weekly, so a CodeQL query update that finds something in -# unchanged code is noticed without a push. -# workflow_call dry-run.yml and release.yml analyse the exact commit they -# test; their `codeql` job (analyze + alerts) must pass. -# workflow_dispatch a manual re-run on any ref. +# FULL scan -- push to main, weekly, dry run, release: every language; c-cpp +# traces the whole production build, vendored C included. +# PR scan -- pr.yml's `codeql` job (light: true), needed by ci-ok. One leg per +# language whose scope the PR changes (codeql-* lanes of +# scripts/ci/select-lanes.sh; an undeterminable diff or a change to the +# workflows, the build or the CodeQL machinery selects every language). +# Each leg analyses its language's WHOLE first-party scope, so cross-file +# data flow is kept; c-cpp compiles the vendored objects before tracing +# starts, except vendored files the PR changes. Diff-informed analysis then +# restricts the REPORTED alerts to the changed lines; what is extracted and +# analysed is never narrowed by the diff. +# There is no pull_request trigger and no job anywhere that polls for a CodeQL +# run: the former PR gate waited up to 150 min per PR for a full analysis and, +# with ~30 PRs pushed at once, starved PR CI for hours. on: push: branches: [main] schedule: - cron: "23 5 * * 1" workflow_dispatch: + inputs: + light: + description: "PR-style c-cpp build (vendored objects compiled untraced) -- to time it" + type: boolean + default: false workflow_call: + inputs: + light: + description: "PR scan: vendored C compiled untraced except changed files; diff-informed results" + type: boolean + default: false + matrix: + description: 'The legs to run, {"include":[...]} (pr.yml: the selected codeql-* lanes); empty = every language' + type: string + default: "" permissions: contents: read @@ -26,17 +45,25 @@ permissions: jobs: analyze: runs-on: ubuntu-latest - # A newer push to main cancels the analysis of an older main commit; only - # the newest commit's results matter for main. Every other trigger gets a - # group of its own and is never cancelled, so a dry run and a release - # dispatched together cannot cancel each other's gate. In a called workflow - # `github` is the CALLER's context: github.workflow names the dry run or - # the release, github.run_id is the caller's run. + strategy: + fail-fast: false + # The same legs (and keys) everywhere, so a PR's analysis and main's + # share one code-scanning configuration per language. + matrix: ${{ fromJSON(inputs.matrix || '{"include":[{"language":"c-cpp","build-mode":"manual","lane":"codeql-cpp"},{"language":"javascript-typescript","build-mode":"none","lane":"codeql-js"},{"language":"python","build-mode":"none","lane":"codeql-py"},{"language":"actions","build-mode":"none","lane":"codeql-actions"}]}') }} + # Superseded runs are always cancelled: a newer push to main cancels the + # analysis of an older main commit (batched merges -> only the newest main + # commit is scanned), a newer push to a PR cancels that PR's scan. Every + # other trigger gets a group of its own and is never cancelled: a dry run + # or a release tests ONE commit, and two dispatched together must not + # cancel each other's gate. Per language, so legs never cancel each other. + # In a called workflow `github` is the CALLER's context: event_name is + # pull_request for pr.yml, github.workflow names the dry run or release. concurrency: - group: codeql-${{ github.event_name == 'push' && github.ref || format('{0}-{1}', github.workflow, github.run_id) }} - cancel-in-progress: ${{ github.event_name == 'push' }} - # CodeQL needs security-events: write to upload results; scoped to this job so - # the workflow's top-level token stays read-only (Scorecard TokenPermissions). + group: codeql-${{ matrix.language }}-${{ github.event_name == 'push' && github.ref || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} + # CodeQL needs security-events: write to upload results (and read for the + # alert check); scoped to this job so the workflow's top-level token stays + # read-only (Scorecard TokenPermissions). permissions: security-events: write actions: read @@ -44,54 +71,80 @@ jobs: env: # One code-scanning configuration for every trigger. The action derives # the key from the RUN's workflow file, which for a called workflow is - # the caller: dry-run.yml / release.yml would each open a configuration - # of their own on main, and an alert fixed by a later push would stay - # open in the stale one until the next dry run. The value is the key the - # push-to-main analysis has always used (the action documents this - # variable as settable for non-standard setups). + # the caller: pr.yml / dry-run.yml / release.yml would each open a + # configuration of their own, and on main an alert fixed by a later push + # would stay open in a stale one. The value is the key the push-to-main + # analysis has always used (the action documents this variable as + # settable for non-standard setups). CODEQL_ACTION_ANALYSIS_KEY: .github/workflows/codeql.yml:analyze + # Diff-informed analysis: codeql-action v4.38.1 (CLI 2.27.0) reports a + # pull request's alerts only on its changed lines when the + # diff_informed_queries feature is on (default on; this variable forces + # it, src/feature-flags.ts). It applies only when the event payload is a + # pull request -- pr.yml's call -- and is inert on main, dry run, release. + CODEQL_ACTION_DIFF_INFORMED_QUERIES: ${{ inputs.light && 'true' || '' }} + # No compiler cache: a cache hit skips the compiler, and CodeQL extracts + # only what a traced compiler invocation actually compiles. + CBM_NO_CCACHE: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The PR scan reads the merge commit's first parent (the base) to + # find the vendored files the PR changes. + fetch-depth: ${{ inputs.light && 2 || 1 }} - name: Install build dependencies + if: ${{ matrix.language == 'c-cpp' }} run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + # PR c-cpp: compile the third-party objects (tree-sitter grammars, + # sqlite, mimalloc, lz4, zstd, the tree-sitter runtime: ~1.3 GB of + # vendored C against ~45 MB first-party) BEFORE tracing starts, so the + # traced build below compiles first-party code only ... + - name: Build third-party objects (PR scan, untraced) + if: ${{ matrix.language == 'c-cpp' && inputs.light }} + run: scripts/build.sh --vendored-only + + # ... except the vendored files this PR changes: those are rebuilt + # under tracing (an undeterminable diff leaves the list empty; the scan + # still covers all first-party C). + - name: Vendored files this PR changes (traced too) + if: ${{ matrix.language == 'c-cpp' && inputs.light }} + run: scripts/ci/codeql-retrace-vendored.sh "$RUNNER_TEMP/codeql-retrace.txt" + - name: Initialize CodeQL uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: - languages: c-cpp - build-mode: manual + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: ./.github/codeql/codeql-config.yml - - name: Build for CodeQL analysis + - name: Build for CodeQL analysis (full) + if: ${{ matrix.language == 'c-cpp' && !inputs.light }} run: scripts/build.sh + - name: Build for CodeQL analysis (PR scan, first-party + changed vendored) + if: ${{ matrix.language == 'c-cpp' && inputs.light }} + run: scripts/build.sh --reuse-vendored --retrace-from "$RUNNER_TEMP/codeql-retrace.txt" + - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1 with: - category: "/language:c-cpp" + category: "/language:${{ matrix.language }}" - # Alert policy: the default branch carries ZERO open code-scanning alerts. - # Any open alert -- a new finding from a merge, from the weekly run, or one - # this commit adds -- fails this job: the main commit turns red, GitHub - # notifies whoever triggered the run, and the dry run and the release (which - # call this workflow) cannot pass until the alert is fixed or dismissed with - # a reason in the Security tab. With a zero baseline, "any open alert" is - # exactly "a new alert". Moved here unchanged from _security.yml's former - # PR-CI codeql-gate job; it reads the default branch's alerts, as it did there. - alerts: - needs: [analyze] - runs-on: ubuntu-latest - timeout-minutes: 10 - # Without security-events:read this job inherits the workflow's - # contents:read, the code-scanning alert API answers 403, and the former - # gate reported "0 alerts" on every run it ever made. - permissions: - contents: read - security-events: read - steps: + # Alert policy: the default branch carries ZERO open code-scanning + # alerts. Any open alert on the analysed ref fails this leg: on main the + # commit turns red and GitHub notifies whoever triggered the run; the + # dry run and the release cannot pass; a PR (refs/pull//merge: what + # its changed lines introduce) fails ci-ok. With a zero baseline "any + # open alert" is exactly "a new alert"; a dismissal (with a reason, in + # the Security tab) holds on every ref. Every leg checks after its own + # upload, so each language's alerts are seen by at least its own leg. + # Moved from _security.yml's former PR-CI codeql-gate job. - name: Check for open code scanning alerts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ALERT_REF: ${{ github.ref }} run: | # FAIL CLOSED. The previous form was `gh api ... 2>/dev/null || echo "0"`, # which looks defensive and is the opposite. gh writes the API error @@ -108,7 +161,7 @@ jobs: # replaces. The callers below check the status explicitly. count_open_alerts() { local body status - body=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ + body=$(gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \ --jq 'length' 2>/dev/null) status=$? if [ $status -ne 0 ]; then @@ -132,9 +185,9 @@ jobs: ALERTS2=$(count_open_alerts) || exit 1 [ "$ALERTS" -lt "$ALERTS2" ] && ALERTS=$ALERTS2 if [ "$ALERTS" -gt 0 ]; then - echo "::error title=Open code scanning alerts::$ALERTS open alert(s) on the default branch -- fix, or dismiss with a reason in the Security tab" + echo "::error title=Open code scanning alerts::$ALERTS open alert(s) on $ALERT_REF -- fix, or dismiss with a reason in the Security tab" echo "BLOCKED: $ALERTS open alert(s)" - gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \ + gh api "repos/${{ github.repository }}/code-scanning/alerts?state=open&ref=$ALERT_REF" \ --jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true exit 1 fi diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 3af53e094..463424092 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -3,7 +3,7 @@ # # Pipeline: lint → test → build + candidate scan/select → smoke/soak # Security: security-static + license-gate (independent island) -# CodeQL: codeql.yml on this commit (analyze + alerts) (independent island) +# CodeQL: codeql.yml full scan of this commit + alert check (independent island) # # Security and CodeQL do NOT block lint/test/build/smoke/soak. All jobs must pass # for the overall workflow to be green. @@ -69,11 +69,11 @@ jobs: contents: read secrets: inherit - # ── CodeQL: analysis of this exact commit + the open-alert check ── - # PRs do not run CodeQL; the dry run and the release are where a CodeQL pass - # is required. codeql.yml's analyze job uploads results and its alerts job - # reads them: a called workflow cannot request more than its caller grants, - # so withholding either permission fails the whole run at startup. + # ── CodeQL: full scan of this exact commit + the open-alert check ── + # Every language, vendored C included (PRs run the narrower PR scan). + # codeql.yml uploads results and reads the alerts: a called workflow + # cannot request more than its caller grants, so withholding either + # permission fails the whole run at startup. codeql: uses: ./.github/workflows/codeql.yml permissions: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7df1a2f93..81a838e80 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -125,6 +125,7 @@ jobs: full: ${{ steps.select.outputs.full }} lanes: ${{ steps.select.outputs.lanes }} smoke: ${{ steps.select.outputs.smoke }} + codeql: ${{ steps.select.outputs.codeql }} steps: - name: List the changed files env: @@ -177,6 +178,10 @@ jobs: SMOKE='[{"os":"ubuntu-latest","lane":"smoke-ubuntu"},{"os":"macos-14","lane":"smoke-mac"},{"os":"windows-latest","lane":"smoke-win"}]' LANES=$(sed -n 's/^lanes=//p' "$RUNNER_TEMP/selection.txt") echo "smoke={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$SMOKE")}" >> "$GITHUB_OUTPUT" + # The CodeQL legs, tagged the same way (codeql.yml's full matrix has + # the same entries, so PR and main share a configuration). + CODEQL='[{"language":"c-cpp","build-mode":"manual","lane":"codeql-cpp"},{"language":"javascript-typescript","build-mode":"none","lane":"codeql-js"},{"language":"python","build-mode":"none","lane":"codeql-py"},{"language":"actions","build-mode":"none","lane":"codeql-actions"}]' + echo "codeql={\"include\":$(jq -c --argjson sel "$LANES" 'map(select(.lane | IN($sel[])))' <<< "$CODEQL")}" >> "$GITHUB_OUTPUT" # ── Light smoke: build the PRODUCTION binary and run the core smoke on the # three RELIABLE NATIVE platforms. Catches built-binary regressions at PR @@ -358,13 +363,34 @@ jobs: - name: Contract steps only run: scripts/test.sh --contracts-only + # ── CodeQL PR scan (lanes codeql-cpp / -js / -py / -actions): one leg per + # language whose first-party scope this PR changes; each analyses that + # language's whole first-party scope, c-cpp with the vendored objects + # compiled before tracing (except vendored files the PR changes), and + # reports alerts on the changed lines (diff-informed). A job of this run, + # needed by ci-ok -- nothing waits for a separate CodeQL run. Push to + # main, dry run and release run the full scan (codeql.yml). ── + codeql: + needs: [changes] + if: ${{ !cancelled() && needs.changes.result == 'success' && (contains(fromJSON(needs.changes.outputs.lanes), 'codeql-cpp') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-js') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-py') || contains(fromJSON(needs.changes.outputs.lanes), 'codeql-actions')) }} + uses: ./.github/workflows/codeql.yml + # codeql.yml uploads results and reads alerts: a called workflow cannot + # request more than its caller grants (startup failure otherwise). + permissions: + contents: read + security-events: write + actions: read + with: + light: true + matrix: ${{ needs.changes.outputs.codeql }} + ci-ok: # The one required context (besides dco) — fails unless every PR stage # succeeded, so matrix renames can never silently deadlock merges, and # every lane the selector chose ran green: `skipped` is OK only for a lane # nobody selected (a selected lane that skipped is a silent gate loss). # memwaste and the test-impact shadow are report-only and not needed here. - needs: [changes, security, lint, test, pr-smoke, contracts] + needs: [changes, security, lint, test, pr-smoke, contracts, codeql] if: ${{ always() }} runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9503f966d..707c69924 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ # Release pipeline: lint → test → build → smoke/soak → draft → verify → publish # -# Security (security-static + license-gate) and CodeQL (codeql.yml: analysis +# Security (security-static + license-gate) and CodeQL (codeql.yml: full scan # of this commit + the open-alert check) start immediately and run in # parallel with lint/test/build/smoke/soak. They only block the final verify # step — everything else proceeds independently. @@ -52,11 +52,10 @@ jobs: secrets: inherit # ── CodeQL (starts immediately, blocks only verify) ────────────── - # The analysis of this exact commit + the open-alert check. - # PRs do not run CodeQL; the dry run and the release are where a CodeQL pass - # is required. codeql.yml's analyze job uploads results and its alerts job - # reads them: a called workflow cannot request more than its caller grants, - # so withholding either permission fails the whole run at startup. + # Full scan of this exact commit (every language, vendored C included) + + # the open-alert check. codeql.yml uploads results and reads the alerts: + # a called workflow cannot request more than its caller grants, so + # withholding either permission fails the whole run at startup. codeql: uses: ./.github/workflows/codeql.yml permissions: diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 2c52591a0..6c4b07368 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -52,7 +52,7 @@ Operational authority is stricter than code review authority. | Operation | Current authority | Project rule | | --- | --- | --- | -| PR validation (`pr.yml`, DCO) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. | +| PR validation (`pr.yml`, DCO, PR CodeQL scan) | Automatic | Anyone may trigger it by opening or updating a PR. Required checks must pass. | | Dry run (`dry-run.yml`) | `@DeusData` | Delegated release operators may run dry runs after promotion. Dry-run delegation does not imply release authority. | | Smoke/soak/repro manual runs | `@DeusData` | Area reviewers may operate these runs when delegated for diagnosis. Results are advisory. | | Release workflow (`release.yml`) | `@DeusData` only | Owner-only until a release operator is explicitly promoted. Publishing, replacing releases, and tag movement remain owner-gated. | diff --git a/Makefile.cbm b/Makefile.cbm index 9fc93b73d..3b93146f1 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1299,6 +1299,16 @@ $(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c $(BUILD_DIR)/.build-co OBJS_VENDORED_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LSP_OBJ_PROD) $(PP_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ) +# The part of OBJS_VENDORED_PROD that holds no first-party code: everything but +# prod_lsp_all.o (first-party LSP unity) and prod_preprocessor.o (first-party +# code around vendored simplecpp). CodeQL's light PR scan builds these BEFORE +# it starts tracing (scripts/build.sh --vendored-only), so the traced build +# (--reuse-vendored) compiles first-party code only: the grammars alone are +# most of the 1.3 GB of vendored C, against ~45 MB of first-party code. +OBJS_THIRD_PARTY_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ) +.PHONY: cbm-third-party +cbm-third-party: $(OBJS_THIRD_PARTY_PROD) + MAIN_SRC = src/main.c # Rebuild when the build CONFIGURATION changes, not only when sources do. The diff --git a/SECURITY.md b/SECURITY.md index bbf82af2e..ec9fea712 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -111,7 +111,7 @@ This project implements multiple layers of security verification. Every release - **All dangerous function calls** require a reviewed entry in `scripts/security-allowlist.txt` - **Time-bomb pattern detection** — scans for `time()`/`sleep()` near dangerous calls (could indicate delayed activation) - **MCP tool handler file read audit** — tracks file read count in `mcp.c` against an expected maximum (detects added file reads that could exfiltrate data through tool responses) -- **CodeQL SAST** — static application security testing on every push to main, weekly, and in every dry run and release (taint analysis, CWE detection, data flow tracking). Any open alert fails the main run and blocks the release. +- **CodeQL SAST** — static application security testing of the C/C++, JavaScript/TypeScript, Python and GitHub Actions code (taint analysis, CWE detection, data flow tracking): a full scan on every push to main, weekly, and in every dry run and release; a first-party scan of the languages a pull request changes, with alerts reported on its changed lines. Any open alert fails the run and blocks the release. - **Fuzz testing** — random/mutated inputs to MCP server and Cypher parser (60 seconds per build). Catches crashes, segfaults, and memory errors that structured tests miss. - **Native antivirus scanning** on every platform (any detection fails the build): - **Windows**: Windows Defender with ML heuristics — the same engine end users run diff --git a/scripts/build.sh b/scripts/build.sh index a71029b93..60927257b 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -32,6 +32,15 @@ Options: --with-ui Build the web UI as a content-addressed sidecar (needs node). --version V Stamp the version string (release venue passes the tag). --arch ARCH Force target arch (arm64 | x86_64), e.g. under Rosetta. + --vendored-only Build only the third-party objects (make cbm-third-party) + into a clean BUILD_DIR, no binary. + --reuse-vendored Do NOT clean BUILD_DIR: build the binary on top of a + --vendored-only run with the same arguments, compiling + first-party code only. The pair exists for CodeQL's light + PR scan (codeql.yml): the first runs before tracing starts. + --retrace-from F With --reuse-vendored: F lists vendored paths (one per + line) whose objects must be rebuilt anyway (make -W), so a + vendored file the PR changes is compiled under tracing too. -h, --help This text. Make passthrough (VAR=VAL, forwarded verbatim): @@ -79,6 +88,8 @@ source "$ROOT/scripts/path-safety.sh" # containerized legs can build in their own directory instead of deleting and # clobbering the host's native build/c artifacts. WITH_UI=false +PHASE=all +RETRACE_FROM="" VERSION="" BUILD_DIR="build/c" EXTRA_MAKE_ARGS=() @@ -94,7 +105,13 @@ for arg in "$@"; do --with-ui) WITH_UI=true ;; - --version) + --vendored-only) + PHASE=vendored + ;; + --reuse-vendored) + PHASE=reuse + ;; + --version|--retrace-from) prev_arg="$arg" continue ;; @@ -122,6 +139,8 @@ for arg in "$@"; do # a usage error, not a silent make argument. if [[ "${prev_arg:-}" == "--version" ]]; then VERSION="$arg" + elif [[ "${prev_arg:-}" == "--retrace-from" ]]; then + RETRACE_FROM="$arg" else echo "build.sh: unexpected argument '$arg'. Please consult --help." >&2 exit 2 @@ -144,15 +163,46 @@ echo " ui=$WITH_UI version=${VERSION:-dev}" # Verify compiler supports target arch verify_compiler "$CC" -# Step 1: Clean C build artifacts only (not node_modules — npm ci handles that) -cbm_remove_build_dir "$ROOT" "$BUILD_DIR" +if [[ "$PHASE" != all ]] && $WITH_UI; then + echo "build.sh: --vendored-only / --reuse-vendored do not combine with --with-ui. Please consult --help." >&2 + exit 2 +fi + +# --retrace-from: each listed path becomes `make -W ` (treated as changed +# now -- make's own dependency graph picks the objects; no timestamp race). +RETRACE_ARGS=() +if [[ -n "$RETRACE_FROM" ]]; then + if [[ "$PHASE" != reuse || ! -f "$RETRACE_FROM" ]]; then + echo "build.sh: --retrace-from needs --reuse-vendored and an existing list file. Please consult --help." >&2 + exit 2 + fi + while IFS= read -r retrace_path || [[ -n "$retrace_path" ]]; do + [[ -n "$retrace_path" ]] && RETRACE_ARGS+=(-W "$retrace_path") + done < "$RETRACE_FROM" +fi + +# Step 1: Clean C build artifacts only (not node_modules — npm ci handles that). +# --reuse-vendored keeps the third-party objects of the --vendored-only run. +if [[ "$PHASE" == reuse ]]; then + if [[ ! -d "$BUILD_DIR" ]]; then + echo "build.sh: --reuse-vendored needs a --vendored-only run in $BUILD_DIR first." >&2 + exit 2 + fi +else + cbm_remove_build_dir "$ROOT" "$BUILD_DIR" +fi # Step 2: Build (Makefile applies $ARCHFLAGS for the target arch on macOS) -if $WITH_UI; then +if [[ "$PHASE" == vendored ]]; then + make -j"$NPROC" -f Makefile.cbm cbm-third-party \ + CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" + echo "=== Third-party objects built in ${BUILD_DIR} (no binary) ===" + exit 0 +elif $WITH_UI; then make -j"$NPROC" -f Makefile.cbm cbm-with-ui \ CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" else - make -j"$NPROC" -f Makefile.cbm cbm \ + make -j"$NPROC" -f Makefile.cbm cbm "${RETRACE_ARGS[@]+"${RETRACE_ARGS[@]}"}" \ CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}" fi diff --git a/scripts/ci/codeql-retrace-vendored.sh b/scripts/ci/codeql-retrace-vendored.sh new file mode 100644 index 000000000..d2eee75ec --- /dev/null +++ b/scripts/ci/codeql-retrace-vendored.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# codeql-retrace-vendored.sh — the vendored C/C++ files a pull request changes, +# for the PR c-cpp CodeQL scan (.github/workflows/codeql.yml). +# +# A PR's c-cpp scan compiles the third-party objects BEFORE CodeQL starts +# tracing (scripts/build.sh --vendored-only), so vendored C stays out of the +# analysis, while ALL first-party C is traced -- except the vendored files the +# PR itself changes. This lists those; scripts/build.sh --reuse-vendored +# --retrace-from then rebuilds their objects under tracing (make -W, so +# make's own dependency graph picks the objects). +# +# The changed files come from the PR merge commit (HEAD^1 is the base it was +# merged onto, as in pr.yml's file-list fallback; needs fetch-depth 2) unless +# --changed FILE gives them. An undeterminable diff (no pull request, no +# parent, git failing, an unreadable FILE) is NOT an error and never narrows +# the scan: the list is empty and the scan still covers all first-party C. +# +# Usage: scripts/ci/codeql-retrace-vendored.sh [--changed FILE] OUT +# OUT is written with the vendored paths to retrace, one per line (may be +# empty). The scope test is scripts/ci/select-lanes.sh --codeql-scope c-cpp. +# Exit: 0 = list written · 2 = usage error. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CHANGED_FILE="" +OUT="" +while [ $# -gt 0 ]; do + case "$1" in + -h | --help) + sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + --changed) + CHANGED_FILE="${2:-}" + [ -n "$CHANGED_FILE" ] || { + echo "codeql-retrace-vendored.sh: --changed needs a file. Please consult --help." >&2 + exit 2 + } + shift + ;; + -*) + echo "codeql-retrace-vendored.sh: unknown option '$1'. Please consult --help." >&2 + exit 2 + ;; + *) + if [ -n "$OUT" ]; then + echo "codeql-retrace-vendored.sh: more than one output file. Please consult --help." >&2 + exit 2 + fi + OUT="$1" + ;; + esac + shift +done +if [ -z "$OUT" ]; then + echo "codeql-retrace-vendored.sh: no output file. Please consult --help." >&2 + exit 2 +fi + +changed="" +determined=false +if [ -n "$CHANGED_FILE" ]; then + if [ -r "$CHANGED_FILE" ]; then + changed=$(cat "$CHANGED_FILE") + determined=true + fi +elif [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then + if changed=$(git -C "$ROOT" diff --name-only --no-renames HEAD^1 HEAD 2>/dev/null); then + determined=true + fi +fi + +: >"$OUT" +if ! $determined; then + echo "diff undetermined: no vendored file to retrace; the scan covers all first-party C" + exit 0 +fi +printf '%s\n' "$changed" | + bash "$ROOT/scripts/ci/select-lanes.sh" --codeql-scope c-cpp | + while IFS= read -r path; do + case "$path" in + internal/cbm/vendored/* | vendored/*) + if [ -f "$ROOT/$path" ]; then printf '%s\n' "$path"; fi + ;; + esac + done >"$OUT" +count=$(wc -l <"$OUT" | tr -d ' ') +echo "vendored files to retrace: $count" +cat "$OUT" diff --git a/scripts/ci/require-all-green.sh b/scripts/ci/require-all-green.sh index c1d916e49..149eef2da 100755 --- a/scripts/ci/require-all-green.sh +++ b/scripts/ci/require-all-green.sh @@ -71,7 +71,8 @@ if 'LANES' in os.environ: 'test / test-lsan-macos': 'lsan-macos', 'test / test-windows-guards': 'windows-guards', 'test / shard-completeness': 'shard-completeness', 'contracts': 'contracts', } - matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (') + matrix = ('test / test-unix (', 'test / test-windows (', 'test / test-tsan (', 'pr-smoke (', + 'codeql / analyze (') def lane_of(name): if name in single: return single[name] diff --git a/scripts/ci/select-lanes.sh b/scripts/ci/select-lanes.sh index 56a26fc67..18f72c578 100755 --- a/scripts/ci/select-lanes.sh +++ b/scripts/ci/select-lanes.sh @@ -30,13 +30,19 @@ # replay's single process). # scripts/ci/select-lanes.sh --list-lanes # Every lane name, one per line (the universe ci-ok checks against). +# scripts/ci/select-lanes.sh --codeql-scope LANGUAGE [FILE] +# The paths of FILE (default stdin) in that CodeQL language's PR +# scope (c-cpp, javascript-typescript, python, actions), one per line. +# The codeql-* lanes are selected by these scopes; the interpreted +# ones follow .github/codeql/codeql-config.yml (paths/paths-ignore). # Exit: 0 = decided · 2 = usage error. set -euo pipefail -usage() { sed -n '2,33p' "$0" | sed 's/^# \{0,1\}//'; } +usage() { sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; } FORMAT=json MODE="select" +CODEQL_LANGUAGE="" INPUT="" EXPECT="" while [ $# -gt 0 ]; do @@ -56,6 +62,11 @@ while [ $# -gt 0 ]; do [ $# -gt 1 ] && shift ;; --list-lanes) MODE="list" ;; + --codeql-scope) + MODE="codeql-scope" + CODEQL_LANGUAGE="${2:-}" + [ $# -gt 1 ] && shift + ;; --expect-files) # An empty value (a workflow whose count went missing) must fail # loudly, never quietly skip the truncation check. @@ -101,20 +112,29 @@ fi # The heredoc below is the program, so stdin input is staged in a file first. STAGED="" -if [ "$MODE" = select ] && [ -z "$INPUT" ]; then +case "$MODE:$CODEQL_LANGUAGE" in +codeql-scope:c-cpp | codeql-scope:javascript-typescript | codeql-scope:python | codeql-scope:actions | select:* | batch:* | list:*) ;; +*) + echo "select-lanes.sh: --codeql-scope takes c-cpp, javascript-typescript, python or actions. Please consult --help." >&2 + exit 2 + ;; +esac +if { [ "$MODE" = select ] || [ "$MODE" = codeql-scope ]; } && [ -z "$INPUT" ]; then STAGED=$(mktemp "${TMPDIR:-/tmp}/cbm-select-lanes.XXXXXX") trap 'rm -f "$STAGED"' EXIT cat >"$STAGED" INPUT="$STAGED" fi -python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" <<'PY' +CODEQL_CONFIG="$(cd "$(dirname "$0")/../.." && pwd)/.github/codeql/codeql-config.yml" +python3 - "$MODE" "$FORMAT" "$INPUT" "$EXPECT" "$CODEQL_CONFIG" "$CODEQL_LANGUAGE" <<'PY' import json import re import sys mode, fmt, source = sys.argv[1:4] expect = int(sys.argv[4]) if sys.argv[4] else None +codeql_config, codeql_language = sys.argv[5:7] # ── Path -> category (first matching rule wins; order is part of the rule) ── GATE_WORKFLOWS = {"pr.yml", "_test.yml", "_lint.yml", "_security.yml", @@ -192,6 +212,8 @@ def category(path): return "grammar" if path.startswith(".github/workflows/"): return "ci-gate" if base in GATE_WORKFLOWS else "ci-other" + if path.startswith(".github/codeql/"): + return "ci-gate" if path.startswith(".github/"): if path.endswith(".md") or ("ISSUE_TEMPLATE" in path and path.endswith((".yml", ".yaml"))): return "docs" @@ -293,11 +315,16 @@ def tier_of(cats): # ── Tier -> lanes (R1) ── +# The PR CodeQL scans, one lane per language (pr.yml's `codeql` matrix). Each +# is selected by a change in its scope (codeql_scope below) and, like every +# lane, by any full run: an undeterminable diff scans every language. +CODEQL_LANES = {"c-cpp": "codeql-cpp", "javascript-typescript": "codeql-js", + "python": "codeql-py", "actions": "codeql-actions"} FULL = {"diag", "license-gate", "lint", "lint-mem", "lsan-macos", "memwaste", "msan", "pkg-wrappers", "security-static", "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", - "windows-guards"} + "windows-guards"} | set(CODEQL_LANES.values()) UNIVERSE = FULL | {"contracts"} # contracts: the T0a/T0b job; every test leg runs them too LINT = {"lint", "lint-mem"} CORE_LEGS = {"unix-x86", "unix-macos14", "windows", "shard-completeness"} @@ -310,7 +337,7 @@ R1 = { "T1-tests": LINT | CORE_LEGS, "T2-extract": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, "T3-mid": LINT | CORE_LEGS | {"tsan-x86", "smoke-ubuntu"}, - "T4-platform": FULL - {"msan", "diag", "memwaste"}, + "T4-platform": FULL - {"msan", "diag", "memwaste"} - set(CODEQL_LANES.values()), "T5-core": FULL, } # Category add-ons: (categories, lanes). pkg adds nothing to a docs-only set @@ -328,6 +355,73 @@ HARNESS = {"scripts/test.sh", "scripts/run-tests-parallel.sh", "scripts/run-test "tests/test_main.c", "tests/test_framework.h"} +# ── CodeQL PR scope (the codeql-* lanes) ── +# c-cpp: any C/C++ the production build can compile -- first-party AND +# vendored (a vendored-only change is scanned: codeql.yml traces the vendored +# files a PR changes). Tests are not in the analysed build. +# Interpreted languages: their extensions inside the CodeQL config's +# paths minus paths-ignore (.github/codeql/codeql-config.yml, read below). +# A change to the CodeQL machinery itself scans every language (fail closed); +# those paths are full runs anyway, this keeps that true if a tier moves. +CODEQL_C_EXT = (".c", ".h", ".cc", ".cpp", ".cxx", ".hh", ".hpp", ".hxx", ".inc") +CODEQL_C_ROOTS = ("src/", "internal/", "vendored/") +CODEQL_EXT = {"javascript-typescript": (".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", + ".mts", ".cts", ".vue", ".html", ".htm"), + "python": (".py",)} +CODEQL_FAIL_CLOSED_PREFIXES = (".github/workflows/", ".github/codeql/") +CODEQL_FAIL_CLOSED = {"Makefile.cbm", "scripts/build.sh", "scripts/ci/select-lanes.sh", + "scripts/ci/codeql-retrace-vendored.sh"} + + +def config_lists(path): + """paths / paths-ignore of the CodeQL config: `key:` then ` - item` lines. + None when the file is unreadable -- every interpreted scope then matches + by extension alone (fail closed).""" + try: + lines = open(path, encoding="utf-8").read().splitlines() + except OSError: + return None + lists, key = {"paths": [], "paths-ignore": []}, None + for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue + m = re.match(r"^([A-Za-z-]+):\s*$", line) + if m: + key = m.group(1) if m.group(1) in lists else None + continue + m = re.match(r"^\s+-\s+['\"]?([^'\"#]+?)['\"]?\s*$", line) + if m and key: + lists[key].append(m.group(1).rstrip("/")) + elif not line.startswith((" ", "\t")): + key = None + return lists + + +CODEQL_PATHS = config_lists(codeql_config) + + +def under(path, prefixes): + return any(path == p or path.startswith(p + "/") for p in prefixes) + + +def codeql_fail_closed(path): + return path in CODEQL_FAIL_CLOSED or path.startswith(CODEQL_FAIL_CLOSED_PREFIXES) + + +def codeql_scope(language, path): + if codeql_fail_closed(path): + return True + if language == "c-cpp": + return path.startswith(CODEQL_C_ROOTS) and path.endswith(CODEQL_C_EXT) + if CODEQL_PATHS is not None and (not under(path, CODEQL_PATHS["paths"]) + or under(path, CODEQL_PATHS["paths-ignore"])): + return False + if language == "actions": + return path.startswith(".github/workflows/") or \ + path.rsplit("/", 1)[-1] in ("action.yml", "action.yaml") + return path.endswith(CODEQL_EXT[language]) + + def triggers(path, cat): found = [] if path == "scripts/ci/select-lanes.sh": @@ -380,6 +474,12 @@ def select(paths, expect=None): lanes = lanes | extra if lanes & TEST_LEGS: lanes = lanes | {"shard-completeness"} + for language, lane in sorted(CODEQL_LANES.items()): + hits = [p for p in paths if codeql_scope(language, p)] + if hits and lane not in lanes: + more = f" (+{len(hits) - 1} more)" if len(hits) > 1 else "" + reasons.append(f"codeql {language}: {hits[0]}{more}") + lanes = lanes | {lane} for trigger, hits in by_trigger.items(): more = f" (+{len(hits) - 1} more)" if len(hits) > 1 else "" reasons.append(f"full: {trigger}: {hits[0]}{more}") @@ -407,6 +507,10 @@ def read_paths(path): if mode == "list": print("\n".join(sorted(UNIVERSE))) +elif mode == "codeql-scope": + scoped = sorted({p for p in read_paths(source) if p and codeql_scope(codeql_language, p)}) + if scoped: + print("\n".join(scoped)) elif mode == "batch": with open(source, encoding="utf-8") as fh: for line in fh: diff --git a/scripts/test-impact/fixtures/2026-09-real-failures.tsv b/scripts/test-impact/fixtures/2026-09-real-failures.tsv index 6b6c92cc5..ce944f6a6 100644 --- a/scripts/test-impact/fixtures/2026-09-real-failures.tsv +++ b/scripts/test-impact/fixtures/2026-09-real-failures.tsv @@ -2,12 +2,14 @@ # pushes, from the lane analysis ground truth: which lane caught each. The # replay asserts the selector selects that lane for that push. # Columns (TAB): run_id, attempt, pr, head_sha, lane, classification. +# The three codeql-action-bump failures were caught by the codeql-gate lane; +# CodeQL now runs inside PR CI as the codeql-* lanes, so their lane is codeql-cpp. 34263688699 1 2115 013e1f7ec7d4b981a1cd28bf35d5767c195f8447 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) 34288397854 1 2115 92cb3b9a4bebbc79bd283e447104669ca068ea64 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) 34649406520 1 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) 34649406520 2 2115 78a6d19f1eaaa73054639ab0f14c372c53cd1bb3 unix-arm64 real: cli suite hang on slow runners (arm64, then intel) -34676336284 1 2185 eb5e34cfc141454e849daf413f648e73f74c455a codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) -34676429093 1 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +34676336284 1 2185 eb5e34cfc141454e849daf413f648e73f74c455a codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +34676429093 1 2187 cea35fcc0520a0ba95139bd0210052f09b9cfa20 codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) 34696991657 1 2115 9a460b389ce3002ac164605c3627bb86aa3b9be3 unix-macos-intel real: cli suite hang on slow runners (arm64, then intel) 34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 lsan-macos real: macOS-only LSan leaks (memory-core) 34805101784 1 2202 7b77fd4834afe355cc4c7326ac3a55f0502f7264 msan real?: MSan stack overflow (memory-core, branch-specific) @@ -23,4 +25,4 @@ 35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-mac real: cache CLI broken on macOS/Windows 35483768695 1 2236 d2dd3b347bbdcc695f911e80b1b7f7a32d790e5a smoke-win real: cache CLI broken on macOS/Windows 35493947102 1 2236 2caafc0c41bd083092afeb692824e718a8e1e007 smoke-mac real: cache CLI broken on macOS/Windows -36242675954 1 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb codeql-gate real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) +36242675954 1 2363 363575af920b05f911fc76ca964dcfa7ef1c62bb codeql-cpp real: codeql-action bump broke CodeQL (still caught by requiring CodeQL/analyze) diff --git a/tests/fixtures/select-lanes/cases.tsv b/tests/fixtures/select-lanes/cases.tsv index 01996dc22..29d07ca33 100644 --- a/tests/fixtures/select-lanes/cases.tsv +++ b/tests/fixtures/select-lanes/cases.tsv @@ -10,7 +10,7 @@ npm-readme-runs-the-test-legs pkg/npm/README.md T1-tests false lint lint-mem sec issue-template-is-docs .github/ISSUE_TEMPLATE/bug.yml T0a-docs false contracts security-static local-infra test-infrastructure/run.sh T0b-nonproduct false contracts lint lint-mem security-static lint-config scripts/memory-core-baseline.txt T0b-nonproduct false contracts lint lint-mem security-static -graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards +graph-ui-adds-guards graph-ui/src/App.tsx T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards codeql-js memwaste-tools-add-memwaste scripts/memwaste.sh T0b-nonproduct false contracts lint lint-mem memwaste security-static non-gating-script-runs-contracts-leg scripts/benchmark-index.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 test-impact-tooling scripts/test-impact/replay-selector.sh T0b-nonproduct false contracts lint lint-mem security-static shard-completeness unix-x86 @@ -19,19 +19,19 @@ test-fixture tests/fixtures/cpp_include/a.h T1-tests false lint lint-mem securit contract-test tests/test_version_metadata_contract.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows fuzz-harness tests/fuzz/fuzz_cypher.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows docs-plus-tests README.md tests/test_store.c T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows -lsp-language internal/cbm/lsp/go_lsp.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -lsp-core internal/cbm/lsp/type_registry.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -extraction internal/cbm/extract_calls.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -pipeline src/pipeline/pass_calls.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows -daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste -main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste -foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste -windows-guard-tests tests/windows/guard.ps1 T4-platform false FULL -msan -diag -memwaste -smoke-harness scripts/smoke-local.sh T4-platform false FULL -msan -diag -memwaste -package-wrappers pkg/npm/package.json T4-platform false FULL -msan -diag -memwaste +lsp-language internal/cbm/lsp/go_lsp.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +lsp-core internal/cbm/lsp/type_registry.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +grammar-is-extract-not-full internal/cbm/vendored/grammars/go/parser.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +extraction internal/cbm/extract_calls.c T2-extract false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +pipeline src/pipeline/pass_calls.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +mcp-plus-tests src/mcp/mcp.c tests/test_mcp.c T3-mid false lint lint-mem security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp +mid-plus-memwaste-tools src/store/store.c scripts/memwaste-report.py T3-mid false lint lint-mem memwaste security-static shard-completeness smoke-ubuntu tsan-x86 unix-macos14 unix-x86 windows codeql-cpp codeql-py +daemon src/daemon/application.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions +main-c-is-cli src/main.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions +foundation-non-memory src/foundation/log.c T4-platform false FULL -msan -diag -memwaste -codeql-js -codeql-py -codeql-actions +windows-guard-tests tests/windows/guard.ps1 T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions +smoke-harness scripts/smoke-local.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions +package-wrappers pkg/npm/package.json T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions memory-core src/foundation/mem_core.c T5-core true FULL gate-script scripts/ci/require-all-green.sh T5-core true FULL the-selector-itself scripts/ci/select-lanes.sh T5-core true FULL @@ -50,8 +50,8 @@ lint-config-file .cppcheck T0b-nonproduct false contracts lint lint-mem security script-c-tests-read scripts/security-vendored.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows engine-config-c-tests-read .codebase-memory.json T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows test-failure-report scripts/suite-failure-report.sh T1-tests false lint lint-mem security-static shard-completeness unix-macos14 unix-x86 windows -smoke-security-script scripts/security-strings.sh T4-platform false FULL -msan -diag -memwaste -shipped-notices-are-pkg scripts/gen-third-party-notices.sh T4-platform false FULL -msan -diag -memwaste +smoke-security-script scripts/security-strings.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions +shipped-notices-are-pkg scripts/gen-third-party-notices.sh T4-platform false FULL -msan -diag -memwaste -codeql-cpp -codeql-js -codeql-py -codeql-actions non-gate-workflow-forces-full .github/workflows/stale.yml T0b-nonproduct true FULL harness-main-forces-full tests/test_main.c T1-tests true FULL harness-framework-forces-full tests/test_framework.h T1-tests true FULL @@ -59,3 +59,7 @@ harness-driver-forces-full scripts/test.sh T5-core true FULL harness-scheduler-forces-full scripts/run-test-wave.py T5-core true FULL lower-tier-plus-trigger-is-full docs/x.md src/pipeline/a.c tests/test_main.c T3-mid true FULL empty-list-fails-safe - T0a-docs true FULL +graph-ui-python-agent graph-ui/agents/agent.py T0b-nonproduct false codeql-py contracts lint lint-mem security-static smoke-ubuntu windows-guards +vendored-js-is-not-scanned graph-ui/vendor/lib.js T0b-nonproduct false contracts lint lint-mem security-static smoke-ubuntu windows-guards +vendored-only-c-scans-c vendored/sqlite3/sqlite3.c T5-core true FULL +codeql-config-is-full .github/codeql/codeql-config.yml T5-core true FULL diff --git a/tests/test_lane_wiring_contract.sh b/tests/test_lane_wiring_contract.sh index a2a73511f..bd49dc2f7 100755 --- a/tests/test_lane_wiring_contract.sh +++ b/tests/test_lane_wiring_contract.sh @@ -156,7 +156,8 @@ require("test_selection: ${{ needs.select-tests.result == 'success' && " MATRIX = {"unix-x86": "_test.yml", "unix-arm64": "_test.yml", "unix-macos14": "_test.yml", "unix-macos-intel": "_test.yml", "windows": "_test.yml", "tsan-x86": "_test.yml", "tsan-arm": "_test.yml", "tsan-mac": "_test.yml", "smoke-ubuntu": "pr.yml", - "smoke-mac": "pr.yml", "smoke-win": "pr.yml"} + "smoke-mac": "pr.yml", "smoke-win": "pr.yml", "codeql-cpp": "pr.yml", + "codeql-js": "pr.yml", "codeql-py": "pr.yml", "codeql-actions": "pr.yml"} SINGLE = {"lint": ("_lint.yml", "lint"), "lint-mem": ("_lint.yml", "lint-mem"), "license-gate": ("_security.yml", "license-gate"), "pkg-wrappers": ("_test.yml", "test-package-wrappers"), diff --git a/tests/test_security_gate_fail_closed.sh b/tests/test_security_gate_fail_closed.sh index 73eeaa71d..58792f37f 100644 --- a/tests/test_security_gate_fail_closed.sh +++ b/tests/test_security_gate_fail_closed.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# The code-scanning gate must FAIL CLOSED. +# The code-scanning gate must FAIL CLOSED -- and CodeQL must run where it gates. # -# History. `codeql-gate` in .github/workflows/_security.yml (since moved, as -# the `alerts` job, to .github/workflows/codeql.yml) counted open alerts with: +# History. `codeql-gate` in .github/workflows/_security.yml (since moved into +# .github/workflows/codeql.yml as the analyze job's alert check) counted open +# alerts with: # # ALERTS=$(gh api '.../code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0") # @@ -20,9 +21,19 @@ # # A security gate that cannot read its input must stop the build, not wave it # through. This test pins both halves of that: the permission that makes the -# read possible, and the fail-closed handling for when it still is not. It also -# pins where the gate runs: never in PR CI (it starved PR CI of runners), on -# push to main, and inside the dry run and the release, whose verify needs it. +# read possible, and the fail-closed handling for when it still is not. +# +# It also pins where CodeQL runs. The former PR gate polled up to 150 min per +# PR for a separate CodeQL run and starved PR CI of runners; now: +# - no pull_request trigger and no job that polls for a CodeQL run; +# - PR CI calls codeql.yml as its own job (needed by ci-ok), one leg per +# selected codeql-* lane; each leg analyses its language's whole +# first-party scope (never narrowed by the diff); c-cpp compiles the +# vendored objects before tracing, then traces the first-party build plus +# the vendored files the PR changes; +# - push to main, the dry run and the release run the full scan, and the +# release's verify needs it; +# - superseded push and PR runs are cancelled, dispatched ones never. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -30,6 +41,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" python3 - "$ROOT" <<'PY' from __future__ import annotations +import json import pathlib import re import sys @@ -44,7 +56,10 @@ def require(condition: bool, message: str) -> None: workflow_dir = root / ".github" / "workflows" -workflow = (workflow_dir / "codeql.yml").read_text(encoding="utf-8") + + +def text(name: str) -> str: + return (workflow_dir / name).read_text(encoding="utf-8") def job_body(source: str, job: str) -> str | None: @@ -52,28 +67,36 @@ def job_body(source: str, job: str) -> str | None: return m.group("body") if m else None -# Isolate the alerts job so a permission granted to some OTHER job cannot -# satisfy this test. (It was _security.yml's codeql-gate job until CodeQL -# left PR CI; the logic moved unchanged.) -gate = job_body(workflow, "alerts") -require(gate is not None, "codeql.yml no longer defines an alerts job") +def code_only(source: str) -> str: + return "\n".join(line for line in source.splitlines() if not line.lstrip().startswith("#")) + + +def steps(body: str) -> list[tuple[str, str]]: + """(name, step text) per `- name:`/`- uses:` step of a job body.""" + found = [] + for m in re.finditer(r"(?ms)^ - (?P.*?)(?=^ - |\Z)", body): + step = m.group("step") + name = re.search(r"(?m)^(?:name: |\s+name: )(.*)$", step) + found.append((name.group(1).strip() if name else step.splitlines()[0], step)) + return found + + +workflow = text("codeql.yml") +gate = job_body(workflow, "analyze") +require(gate is not None, "codeql.yml no longer defines an analyze job") body = gate or "" -require( - re.search(r"^ needs: \[analyze\]\s*$", body, re.M) is not None, - "codeql.yml alerts must need analyze -- it counts the alerts that analysis uploaded", -) # 1. The read has to be possible at all. require( re.search(r"^ permissions:\s*$", body, re.M) is not None, - "alerts must declare its own permissions block -- inheriting the " + "analyze must declare its own permissions block -- inheriting the " "workflow default leaves it without security-events access and the alert " "API answers 403", ) require( - re.search(r"^ security-events:\s*read\s*$", body, re.M) is not None, - "alerts must request security-events: read, or the code-scanning " - "alert query 403s and the gate cannot see any alert", + re.search(r"^ security-events:\s*(read|write)\s*$", body, re.M) is not None, + "analyze must hold security-events (write implies read), or the " + "code-scanning alert query 403s and the gate cannot see any alert", ) # 2. The failure direction. `|| echo "0"` on the alert query is the specific @@ -85,11 +108,11 @@ require( ) require( "BLOCKED: cannot read code scanning alerts" in body, - "alerts must fail with a named error when the alert API cannot be read", + "the alert check must fail with a named error when the alert API cannot be read", ) require( re.search(r"alert count was not a number", body) is not None, - "alerts must reject a non-numeric alert count instead of comparing it", + "the alert check must reject a non-numeric alert count instead of comparing it", ) # 3. The count is produced in a command substitution, so a bare `exit` inside @@ -106,60 +129,123 @@ require( "inside a command substitution does not stop the caller on its own", ) -# 4. Where CodeQL runs. Never per pull request (a per-PR analysis plus a PR-CI -# job waiting up to 150 min for it starved PR CI of runners); on push to -# main, and callable so the dry run and the release can require it. +# 4. Triggers: never per pull request (PR CI calls the workflow instead), on +# push to main, callable by PR CI, the dry run and the release. on = re.search(r"(?ms)^on:\n(?P.*?)(?=^\S)", workflow) triggers = set(re.findall(r"(?m)^ ([a-z_]+):", on.group("body"))) if on else set() require("pull_request" not in triggers and "pull_request_target" not in triggers, - "codeql.yml must not run per pull request") + "codeql.yml must not trigger on pull requests (pr.yml calls it as a job)") require({"push", "workflow_call"} <= triggers, f"codeql.yml must run on push to main and be callable, has {sorted(triggers)}") require(re.search(r"(?m)^ push:\n branches: \[main\]$", workflow) is not None, "codeql.yml's push trigger must be main") -for name in ("pr.yml", "_security.yml"): - code = "\n".join( - line for line in (workflow_dir / name).read_text(encoding="utf-8").splitlines() - if not line.lstrip().startswith("#") - ) - require("codeql" not in code.lower(), - f"{name} must neither run nor wait for CodeQL (PR CI)") -# 5. Every caller must GRANT what codeql.yml's jobs request. A reusable -# workflow cannot request more permission than its caller holds: asking for -# security-events from a caller that grants only contents:read does not -# downgrade, it fails the entire run with a startup_failure before a single -# job executes. The caller list is DERIVED, never hardcoded: an earlier -# revision listed callers by hand and stayed green while a third caller -# nobody had listed failed every run at startup. +# 5. Nothing waits for a separate CodeQL run, and _security.yml has no CodeQL. +for path in sorted(workflow_dir.glob("*.yml")): + require("workflows/codeql.yml/runs" not in code_only(path.read_text(encoding="utf-8")), + f"{path.name} polls for a CodeQL run -- CodeQL runs as a job of the caller") +require("codeql" not in code_only(text("_security.yml")).lower(), + "_security.yml must not run or wait for CodeQL") + +# 6. Superseded runs are cancelled (push to main, a PR), dispatched never; +# legs of one run never cancel each other. +concurrency = re.search(r"(?ms)^ concurrency:\n(.*?)(?=^ \S)", body) +conc = concurrency.group(1) if concurrency else "" +require("matrix.language" in conc, "the concurrency group must be per language") +require("github.event_name == 'push' && github.ref" in conc, + "push runs must share a ref-scoped group (only the newest main commit is scanned)") +require("github.event.pull_request.number" in conc, + "PR scans must share a per-PR group") +require("cancel-in-progress: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}" in conc, + "only push and PR scans may be cancelled -- a dry run or release tests one commit") + +# 7. The PR scan: analysis never narrowed by the diff; c-cpp prebuilds the +# vendored objects untraced and traces first-party + changed vendored. +names = [name for name, _ in steps(body)] +by_name = dict(steps(body)) + + +def index(fragment: str) -> int: + hits = [i for i, n in enumerate(names) if fragment in n] + return hits[0] if hits else -1 + + +init, analyze = index("Initialize CodeQL"), index("Perform CodeQL Analysis") +prebuild, retrace = index("Build third-party objects"), index("Vendored files this PR changes") +light_build, full_build = index("PR scan, first-party"), index("Build for CodeQL analysis (full)") +require(min(init, analyze, prebuild, retrace, light_build, full_build) >= 0, + f"codeql.yml analyze lost a step, has {names}") +require(-1 < prebuild < init and -1 < retrace < init, + "the vendored prebuild and the retrace list must run BEFORE Initialize CodeQL (untraced)") +require(init < light_build < analyze and init < full_build < analyze, + "the traced builds must run between Initialize CodeQL and the analysis") +for name in names: + if "Initialize CodeQL" in name or "Perform CodeQL Analysis" in name or "open code scanning alerts" in name: + require(not re.search(r"(?m)^\s+if:", by_name[name]), + f"step '{name}' must never be skipped -- a selected leg always analyses") +light_step = by_name.get(names[light_build], "") if light_build >= 0 else "" +require("scripts/build.sh --reuse-vendored --retrace-from" in light_step, + "the PR c-cpp build must reuse the untraced vendored objects and retrace the changed ones") +require("scripts/ci/codeql-retrace-vendored.sh" in (by_name.get(names[retrace], "") if retrace >= 0 else ""), + "the retrace list must come from scripts/ci/codeql-retrace-vendored.sh") +require("scripts/build.sh --vendored-only" in (by_name.get(names[prebuild], "") if prebuild >= 0 else ""), + "the PR c-cpp prebuild must be scripts/build.sh --vendored-only") +require(re.search(r"(?m)^\s+run: scripts/build\.sh\s*$", by_name.get(names[full_build], "") if full_build >= 0 else "") is not None, + "the full c-cpp scan must trace the whole production build (vendored C included)") +require(re.search(r'(?m)^ CBM_NO_CCACHE: "1"$', body) is not None, + "the CodeQL job must disable the compiler cache -- a cache hit compiles nothing to trace") +require("config-file: ./.github/codeql/codeql-config.yml" in body, + "every scan must use the first-party scope in .github/codeql/codeql-config.yml") + +# 8. One configuration per language: the full matrix and PR CI's legs agree. +default = re.search(r"fromJSON\(inputs\.matrix \|\| '(?P\{.*?\})'\)", body) +pr = text("pr.yml") +pr_legs = re.search(r"CODEQL='(?P\[.*?\])'", pr) +full_legs = json.loads(default.group("json"))["include"] if default else [] +require({leg.get("language") for leg in full_legs} == + {"c-cpp", "javascript-typescript", "python", "actions"}, + "the full scan must cover c-cpp, javascript-typescript, python and actions") +require(pr_legs is not None and json.loads(pr_legs.group("json")) == full_legs, + "pr.yml's CodeQL legs must equal codeql.yml's full matrix (one configuration per language)") + +# 9. Every caller GRANTS what codeql.yml requests (a called workflow cannot +# request more than its caller holds: startup failure, not a downgrade). +# The caller list is DERIVED, never hardcoded: an earlier revision listed +# callers by hand and stayed green while a third caller nobody had listed +# failed every run at startup. callers = sorted( path.name for path in workflow_dir.glob("*.yml") if "uses: ./.github/workflows/codeql.yml" in path.read_text(encoding="utf-8") ) -require( - {"dry-run.yml", "release.yml"} <= set(callers), - "the dry run and the release must call codeql.yml (their CodeQL pass), callers: " - + (", ".join(callers) or "none"), -) +require({"pr.yml", "dry-run.yml", "release.yml"} <= set(callers), + "PR CI, the dry run and the release must call codeql.yml, callers: " + + (", ".join(callers) or "none")) for caller_name in callers: - caller = (workflow_dir / caller_name).read_text(encoding="utf-8") - call_body = job_body(caller, "codeql") or "" - require( - "uses: ./.github/workflows/codeql.yml" in call_body, - f"{caller_name} must call codeql.yml from a job named codeql", - ) + call_body = job_body(text(caller_name), "codeql") or "" + require("uses: ./.github/workflows/codeql.yml" in call_body, + f"{caller_name} must call codeql.yml from a job named codeql") for perm, level in (("security-events", "write"), ("actions", "read"), ("contents", "read")): - require( - re.search(rf"^ {perm}:\s*{level}\s*$", call_body, re.M) is not None, - f"{caller_name} must grant {perm}: {level} to the codeql job -- a " - "called workflow cannot request more than its caller grants, and the " - "run fails at startup rather than degrading", - ) + require(re.search(rf"^ {perm}:\s*{level}\s*$", call_body, re.M) is not None, + f"{caller_name} must grant {perm}: {level} to the codeql job") + light = re.search(r"(?m)^ light: true$", call_body) is not None + require(light == (caller_name == "pr.yml"), + f"{caller_name}: only PR CI runs the PR scan (light: true); " + "the dry run and the release run the full scan") -# 6. The release publishes nothing without the CodeQL pass. -release = (workflow_dir / "release.yml").read_text(encoding="utf-8") -verify = job_body(release, "verify") or "" +# 10. PR CI: the codeql job runs for any selected codeql-* lane and ci-ok +# needs it; the release publishes nothing without the CodeQL pass. +pr_codeql = job_body(pr, "codeql") or "" +cond = re.search(r"(?m)^ if: (.*)$", pr_codeql) +for lane in ("codeql-cpp", "codeql-js", "codeql-py", "codeql-actions"): + require(cond is not None and f"'{lane}'" in cond.group(1), + f"pr.yml codeql must run when lane {lane} is selected") +require("matrix: ${{ needs.changes.outputs.codeql }}" in pr_codeql, + "pr.yml codeql must run the legs the changes job selected") +ci_ok = job_body(pr, "ci-ok") or "" +require(re.search(r"(?m)^ needs: \[[^\]]*\bcodeql\b[^\]]*\]$", ci_ok) is not None, + "ci-ok must need the codeql job") +verify = job_body(text("release.yml"), "verify") or "" require(re.search(r"(?m)^ needs: \[[^\]]*\bcodeql\b[^\]]*\]$", verify) is not None, "release.yml verify must need the codeql job") require("needs.codeql.result == 'success'" in verify, diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index 095fcdd13..d195fbce8 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -35,6 +35,7 @@ trap 'rm -rf "$WORK"' EXIT # explicit path): a bare "bash" argv can resolve to the WSL alias on Windows. python3 - "$BASH" "$SELECTOR" "$CASES" "$WORK" <<'PY' import json +import os import pathlib import random import subprocess @@ -52,7 +53,9 @@ FULL = { "shard-completeness", "smoke-mac", "smoke-ubuntu", "smoke-win", "tsan-arm", "tsan-mac", "tsan-x86", "unix-arm64", "unix-macos-intel", "unix-macos14", "unix-x86", "windows", "windows-guards", + "codeql-cpp", "codeql-js", "codeql-py", "codeql-actions", } +CODEQL = {"codeql-cpp", "codeql-js", "codeql-py", "codeql-actions"} UNIVERSE = FULL | {"contracts"} @@ -201,6 +204,85 @@ for bad in ("many", ""): # "" = a workflow whose count went missing failures.append(f"--expect-files {bad!r} must be a usage error (exit 2), never " f"a silently skipped truncation check") +# 8. The CodeQL PR scans (codeql-* lanes) fail closed: +# (a) a C/C++ change -- first-party or vendored -- always selects the C scan; +# (b) an undeterminable diff (an empty or truncated list here; a diff the +# retrace helper cannot compute below) selects every language's full +# first-party scan, and so does a change to the workflows, the build or +# the CodeQL machinery; +# (c) a vendored-only change scans the changed vendored files: the C lane +# is selected and the retrace helper lists exactly those files, which +# codeql.yml then compiles under tracing. +# Tests, docs and vendored non-C select no CodeQL lane. +def lanes_for(files, args=()): + rc, out, _ = run(list(args), "".join(f + "\n" for f in files)) + return set(json.loads(out)["lanes"]) if rc == 0 else None + + +for c_file in ("src/pipeline/pass_calls.c", "src/foundation/mem_core.c", + "internal/cbm/extract_calls.c", "internal/cbm/lsp/go_lsp.c", + "internal/cbm/cbm.h", "src/newdir/x.c", + "internal/cbm/vendored/grammars/go/parser.c", + "internal/cbm/vendored/lz4/lz4.c", "vendored/sqlite3/sqlite3.c", + "vendored/mimalloc/src/options.c"): + got = lanes_for([c_file]) + if got is None or "codeql-cpp" not in got: + failures.append(f"codeql (a): a C/C++ change ({c_file}) must select codeql-cpp, " + f"got {sorted(got or [])}") +for label, files, args in (("empty file list", [], ()), + ("truncated list", ["docs/a.md"], ("--expect-files", "5")), + ("a workflow", [".github/workflows/stale.yml"], ()), + ("Makefile.cbm", ["Makefile.cbm"], ()), + ("scripts/build.sh", ["scripts/build.sh"], ()), + ("the selector", ["scripts/ci/select-lanes.sh"], ()), + ("the CodeQL config", [".github/codeql/codeql-config.yml"], ()), + ("the retrace helper", ["scripts/ci/codeql-retrace-vendored.sh"], ())): + got = lanes_for(files, args) + if got is None or not CODEQL <= got: + failures.append(f"codeql (b): {label} must select every CodeQL language, " + f"got {sorted((got or set()) & CODEQL)}") +for quiet in ("tests/test_store.c", "tests/fixtures/x.py", "README.md", "docs/a.md", + "graph-ui/vendor/lib.js", "scripts/repro.sh"): + got = lanes_for([quiet]) + if got is None or got & CODEQL: + failures.append(f"codeql: {quiet} must select no CodeQL lane, " + f"got {sorted((got or set()) & CODEQL)}") +for language, path in (("javascript-typescript", "graph-ui/src/App.tsx"), + ("javascript-typescript", "pkg/npm/install.js"), + ("python", "scripts/test-impact/x.py"), + ("actions", ".github/workflows/pr.yml")): + rc, out, _ = run(["--codeql-scope", language], path + "\n") + if rc != 0 or out.split() != [path]: + failures.append(f"--codeql-scope {language} must keep {path}, printed {out!r}") +rc, _, err = run(["--codeql-scope", "go"], "src/main.c\n") +if rc != 2 or "Please consult --help." not in err: + failures.append("--codeql-scope with an unknown language must be a usage error (exit 2)") + +retrace = pathlib.Path(selector).parent / "codeql-retrace-vendored.sh" +env = {k: v for k, v in os.environ.items() if k != "GITHUB_EVENT_NAME"} +changed = work / "codeql-changed.txt" +changed.write_text("src/main.c\ninternal/cbm/vendored/grammars/go/parser.c\n" + "vendored/sqlite3/sqlite3.c\nvendored/no-such-file.c\n" + "graph-ui/src/App.tsx\n", encoding="utf-8") +listed = work / "codeql-retrace.txt" +proc = subprocess.run([bash, str(retrace), "--changed", str(changed), str(listed)], + capture_output=True, text=True, env=env) +got = listed.read_text(encoding="utf-8").split() if listed.exists() else None +if proc.returncode != 0 or got != ["internal/cbm/vendored/grammars/go/parser.c", + "vendored/sqlite3/sqlite3.c"]: + failures.append(f"codeql (c): the retrace helper must list exactly the changed vendored " + f"C files that exist, got {got} (exit {proc.returncode})") +for label, args in (("an unreadable change list", ["--changed", str(work / "missing.txt")]), + ("no pull request", [])): + listed.write_text("stale\n", encoding="utf-8") + proc = subprocess.run([bash, str(retrace), *args, str(listed)], + capture_output=True, text=True, env=env) + if proc.returncode != 0 or listed.read_text(encoding="utf-8") != "" \ + or "undetermined" not in proc.stdout: + failures.append(f"codeql (b): with {label} the retrace helper must succeed with an " + f"empty list (the scan still covers all first-party C), " + f"exit {proc.returncode}: {proc.stdout.strip()!r}") + if failures: print("LANE SELECTOR CONTRACT VIOLATED:") for failure in failures: diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index c847106b2..8b4b8b639 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -386,6 +386,7 @@ if [ -d "$ROOT/scripts" ] && [ -f "$ROOT/scripts/test.sh" ]; then HELP_ENTRIES=" scripts/test.sh scripts/build.sh +scripts/ci/codeql-retrace-vendored.sh scripts/lint.sh scripts/smoke-local.sh scripts/soak-legs.sh @@ -433,6 +434,7 @@ test-infrastructure/vm/win.sh STRICT_ENTRIES=" scripts/test.sh scripts/build.sh +scripts/ci/codeql-retrace-vendored.sh scripts/lint.sh scripts/smoke-local.sh scripts/soak-legs.sh From 2f10540b3a9c88d49cf128888dd31e7604195014 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 20 Oct 2026 23:09:26 +0200 Subject: [PATCH 3/4] ci(codeql): make codeql-retrace-vendored.sh executable codeql.yml runs it directly; the file was added without the exec bit. Signed-off-by: Martin Vogel --- scripts/ci/codeql-retrace-vendored.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/ci/codeql-retrace-vendored.sh diff --git a/scripts/ci/codeql-retrace-vendored.sh b/scripts/ci/codeql-retrace-vendored.sh old mode 100644 new mode 100755 From 6a61538f97bebf0258a38d1120b4b58a4e741151 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Wed, 21 Oct 2026 00:07:51 +0200 Subject: [PATCH 4/4] fix(ci): retrace changed vendor families and wrappers Signed-off-by: Martin Vogel --- scripts/build.sh | 3 +- scripts/ci/codeql-retrace-vendored.sh | 48 +++++++++-- scripts/test.sh | 2 + tests/test_codeql_retrace.sh | 110 ++++++++++++++++++++++++++ tests/test_select_lanes.sh | 9 ++- 5 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 tests/test_codeql_retrace.sh diff --git a/scripts/build.sh b/scripts/build.sh index 60927257b..ad646c5de 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -169,7 +169,8 @@ if [[ "$PHASE" != all ]] && $WITH_UI; then fi # --retrace-from: each listed path becomes `make -W ` (treated as changed -# now -- make's own dependency graph picks the objects; no timestamp race). +# now -- the retrace helper supplies direct compilation entrypoints, so nested +# includes do not depend on Makefile wildcard coverage; no timestamp race). RETRACE_ARGS=() if [[ -n "$RETRACE_FROM" ]]; then if [[ "$PHASE" != reuse || ! -f "$RETRACE_FROM" ]]; then diff --git a/scripts/ci/codeql-retrace-vendored.sh b/scripts/ci/codeql-retrace-vendored.sh index d2eee75ec..51e33402e 100755 --- a/scripts/ci/codeql-retrace-vendored.sh +++ b/scripts/ci/codeql-retrace-vendored.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash -# codeql-retrace-vendored.sh — the vendored C/C++ files a pull request changes, +# codeql-retrace-vendored.sh — prebuilt C/C++ translation units to retrace, # for the PR c-cpp CodeQL scan (.github/workflows/codeql.yml). # # A PR's c-cpp scan compiles the third-party objects BEFORE CodeQL starts # tracing (scripts/build.sh --vendored-only), so vendored C stays out of the # analysis, while ALL first-party C is traced -- except the vendored files the -# PR itself changes. This lists those; scripts/build.sh --reuse-vendored -# --retrace-from then rebuilds their objects under tracing (make -W, so -# make's own dependency graph picks the objects). +# PR itself changes, including grammar/runtime wrappers. This lists their +# compilation entrypoints; scripts/build.sh --reuse-vendored --retrace-from +# then rebuilds their objects under tracing. Use direct prerequisites +# for make -W: its shallow wildcard dependencies miss nested headers/sources, +# and compiler include paths can contain aliases such as ../unix/prim.c. # # The changed files come from the PR merge commit (HEAD^1 is the base it was # merged onto, as in pr.yml's file-list fallback; needs fetch-depth 2) unless @@ -16,7 +18,7 @@ # the scan: the list is empty and the scan still covers all first-party C. # # Usage: scripts/ci/codeql-retrace-vendored.sh [--changed FILE] OUT -# OUT is written with the vendored paths to retrace, one per line (may be +# OUT is written with the entrypoints to retrace, one per line (may be # empty). The scope test is scripts/ci/select-lanes.sh --codeql-scope c-cpp. # Exit: 0 = list written · 2 = usage error. set -euo pipefail @@ -78,12 +80,42 @@ fi printf '%s\n' "$changed" | bash "$ROOT/scripts/ci/select-lanes.sh" --codeql-scope c-cpp | while IFS= read -r path; do + entrypoints=() case "$path" in + internal/cbm/grammar_*.c | internal/cbm/ts_runtime.c) + entrypoints=("$path") + ;; + vendored/mimalloc/*) entrypoints=(vendored/mimalloc/src/static.c) ;; + vendored/sqlite3/*) entrypoints=(vendored/sqlite3/sqlite3.c) ;; + vendored/tre/*) entrypoints=(vendored/tre/tre_all.c) ;; + internal/cbm/vendored/lz4/*) + entrypoints=(internal/cbm/vendored/lz4/lz4.c internal/cbm/vendored/lz4/lz4hc.c) + ;; + internal/cbm/vendored/zstd/*) entrypoints=(internal/cbm/vendored/zstd/zstd.c) ;; + internal/cbm/vendored/ts_runtime/*) entrypoints=(internal/cbm/ts_runtime.c) ;; + internal/cbm/vendored/grammars/*) + family="${path#internal/cbm/vendored/grammars/}" + wrapper="internal/cbm/grammar_${family%%/*}.c" + if [ -f "$ROOT/$wrapper" ]; then + entrypoints=("$wrapper") + else + # Shared or unfamiliar grammar code can have many consumers. + entrypoints=("$ROOT"/internal/cbm/grammar_*.c) + fi + ;; + internal/cbm/vendored/common/*) + entrypoints=("$ROOT"/internal/cbm/grammar_*.c) + ;; internal/cbm/vendored/* | vendored/*) - if [ -f "$ROOT/$path" ]; then printf '%s\n' "$path"; fi + # Other vendored code is compiled with the first-party build. + entrypoints=("$path") ;; esac - done >"$OUT" + for entrypoint in "${entrypoints[@]+"${entrypoints[@]}"}"; do + entrypoint="${entrypoint#"$ROOT"/}" + if [ -f "$ROOT/$entrypoint" ]; then printf '%s\n' "$entrypoint"; fi + done + done | LC_ALL=C sort -u >"$OUT" count=$(wc -l <"$OUT" | tr -d ' ') -echo "vendored files to retrace: $count" +echo "prebuilt translation units to retrace: $count" cat "$OUT" diff --git a/scripts/test.sh b/scripts/test.sh index a10a7b330..7f4fea6e8 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -383,6 +383,8 @@ bash "$ROOT/tests/test_setup_scripts_contract.sh" # every September 2026 PR push and real failure against it. echo "=== Step 0z: PR lane selector contract ===" bash "$ROOT/tests/test_select_lanes.sh" +echo "=== Step 0z1: CodeQL retraces affected prebuilt objects ===" +bash "$ROOT/tests/test_codeql_retrace.sh" echo "=== Step 0z2: lane selector history replay ===" bash "$ROOT/scripts/test-impact/replay-selector.sh" echo "=== Step 0z3: lane-aware aggregate gates (ci-ok, shard union) ===" diff --git a/tests/test_codeql_retrace.sh b/tests/test_codeql_retrace.sh new file mode 100644 index 000000000..350271feb --- /dev/null +++ b/tests/test_codeql_retrace.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# Exercise the real production Make rules with tiny C fixtures. CodeQL starts +# tracing after cbm-third-party, so every affected object must compile again, +# while unrelated objects must remain reused. No dry-run/timestamp assertions. +set -euo pipefail +ROOT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +python3 - "$ROOT" "$BASH" <<'PY' +import os +import pathlib +import re +import subprocess +import sys +import tempfile + +root, bash = pathlib.Path(sys.argv[1]), sys.argv[2] +failures = [] +with tempfile.TemporaryDirectory(prefix="cbm-codeql-retrace-") as directory: + work = pathlib.Path(directory) + + def write(path, text): + dest = work / path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(text, encoding="utf-8") + + for path in ("Makefile.cbm", "scripts/ci/codeql-retrace-vendored.sh", + "scripts/ci/select-lanes.sh", ".github/codeql/codeql-config.yml"): + write(path, (root / path).read_text(encoding="utf-8")) + + # Nested and aliased includes deliberately escape the Makefile's shallow + # wildcard dependencies. The compiler really compiles these translation units. + write("vendored/mimalloc/src/static.c", + '#include "mimalloc/internal.h"\n#include "prim/osx/prim.c"\n') + write("vendored/mimalloc/include/mimalloc/internal.h", "#define MI_VALUE 7\n") + write("vendored/mimalloc/src/prim/osx/prim.c", '#include "../unix/prim.c"\n') + write("vendored/mimalloc/src/prim/unix/prim.c", "int mi_value(void) { return MI_VALUE; }\n") + write("internal/cbm/vendored/common/scanner.h", "#define SHARED_VALUE 3\n") + for language in ("go", "json"): + write(f"internal/cbm/grammar_{language}.c", + f'#include "vendored/grammars/{language}/parser.c"\n') + write(f"internal/cbm/vendored/grammars/{language}/parser.c", + '#include "nested/value.h"\n#include "../../common/scanner.h"\n' + f'int grammar_{language}(void) {{ return VALUE + SHARED_VALUE; }}\n') + write(f"internal/cbm/vendored/grammars/{language}/nested/value.h", "#define VALUE 2\n") + write("internal/cbm/vendored/grammars/shared/shared.h", "/* future shared grammar helper */\n") + write("internal/cbm/ts_runtime.c", '#include "vendored/ts_runtime/src/lib.c"\n') + write("internal/cbm/vendored/ts_runtime/src/lib.c", "int runtime_value(void) { return 1; }\n") + for path, symbol in (("vendored/sqlite3/sqlite3.c", "sqlite_value"), + ("vendored/tre/tre_all.c", "tre_value"), + ("internal/cbm/vendored/lz4/lz4.c", "lz4_value"), + ("internal/cbm/vendored/lz4/lz4hc.c", "lz4hc_value"), + ("internal/cbm/vendored/zstd/zstd.c", "zstd_value")): + write(path, f"int {symbol}(void) {{ return 1; }}\n") + write("internal/cbm/vendored/lz4/lz4.h", "/* shared LZ4 header */\n") + write("vendored/sqlite3/sqlite3.h", "/* sqlite header */\n") + write("internal/cbm/vendored/zstd/zstd.h", "/* zstd header */\n") + write("vendored/nomic/code_vectors_blob.S", ".text\n") + write("vendored/nomic/code_vectors.bin", "") + write("src/main.c", "int main(void) { return 0; }\n") + + def run(args): + proc = subprocess.run(args, cwd=work, capture_output=True, text=True) + if proc.returncode: + raise RuntimeError(f"{args}: exit {proc.returncode}\n{proc.stdout}\n{proc.stderr}") + return proc.stdout + + make = ["make", "--no-print-directory", "-f", "Makefile.cbm", "cbm-third-party", + "BUILD_DIR=build/c", "CC=" + os.environ.get("CC", "cc")] + + def compiled(output): + return set(re.findall(r" -c -o build/c/([^\s]+\.o)(?:\s|$)", output)) + + prebuilt = compiled(run(make)) + expected = {"prod_mimalloc.o", "prod_sqlite3.o", "prod_grammar_go.o", + "prod_grammar_json.o", "prod_ts_runtime.o", "prod_lz4.o", + "prod_lz4hc.o", "prod_zstd.o", "unixcoder_blob.o"} + # TRE is a Windows-only production object. All other objects are mandatory. + if prebuilt not in (expected, expected | {"prod_tre.o"}): + raise RuntimeError(f"incomplete prebuild: {sorted(prebuilt)}") + if compiled(run(make)): + raise RuntimeError("unchanged prebuild is not reused") + + cases = [ + ("nested allocator header", ["vendored/mimalloc/include/mimalloc/internal.h"], {"prod_mimalloc.o"}), + ("aliased allocator source", ["vendored/mimalloc/src/prim/unix/prim.c"], {"prod_mimalloc.o"}), + ("nested grammar header", ["internal/cbm/vendored/grammars/go/nested/value.h"], {"prod_grammar_go.o"}), + ("grammar wrapper", ["internal/cbm/grammar_go.c"], {"prod_grammar_go.o"}), + ("runtime wrapper", ["internal/cbm/ts_runtime.c"], {"prod_ts_runtime.o"}), + ("runtime source", ["internal/cbm/vendored/ts_runtime/src/lib.c"], {"prod_ts_runtime.o"}), + ("shared grammar header", ["internal/cbm/vendored/common/scanner.h"], {"prod_grammar_go.o", "prod_grammar_json.o"}), + ("unknown grammar family", ["internal/cbm/vendored/grammars/shared/shared.h"], {"prod_grammar_go.o", "prod_grammar_json.o"}), + ("LZ4 header", ["internal/cbm/vendored/lz4/lz4.h"], {"prod_lz4.o", "prod_lz4hc.o"}), + ("SQLite header", ["vendored/sqlite3/sqlite3.h"], {"prod_sqlite3.o"}), + ("zstd header", ["internal/cbm/vendored/zstd/zstd.h"], {"prod_zstd.o"}), + ("first-party only", ["src/main.c"], set()), + ("duplicate changes", ["internal/cbm/grammar_go.c", "internal/cbm/grammar_go.c"], {"prod_grammar_go.o"}), + ] + for label, paths, expected_objects in cases: + write("changed.txt", "\n".join(paths) + "\n") + run([bash, "scripts/ci/codeql-retrace-vendored.sh", "--changed", "changed.txt", "retrace.txt"]) + retrace = (work / "retrace.txt").read_text(encoding="utf-8").splitlines() + actual = compiled(run(make + [arg for path in retrace for arg in ("-W", path)])) + if actual != expected_objects: + failures.append(f"{label}: expected {sorted(expected_objects)}, compiled {sorted(actual)}") + else: + print(f"PASS {label}: {len(actual)}/{len(prebuilt)} third-party objects retraced") +if failures: + print("CODEQL RETRACE CONTRACT VIOLATED:\n " + "\n ".join(failures)) + sys.exit(1) +print("CodeQL retrace contract OK (real compiler + production Make rules)") +PY diff --git a/tests/test_select_lanes.sh b/tests/test_select_lanes.sh index d195fbce8..175ff88d8 100755 --- a/tests/test_select_lanes.sh +++ b/tests/test_select_lanes.sh @@ -263,15 +263,18 @@ env = {k: v for k, v in os.environ.items() if k != "GITHUB_EVENT_NAME"} changed = work / "codeql-changed.txt" changed.write_text("src/main.c\ninternal/cbm/vendored/grammars/go/parser.c\n" "vendored/sqlite3/sqlite3.c\nvendored/no-such-file.c\n" + "internal/cbm/grammar_json.c\ninternal/cbm/ts_runtime.c\n" "graph-ui/src/App.tsx\n", encoding="utf-8") listed = work / "codeql-retrace.txt" proc = subprocess.run([bash, str(retrace), "--changed", str(changed), str(listed)], capture_output=True, text=True, env=env) got = listed.read_text(encoding="utf-8").split() if listed.exists() else None -if proc.returncode != 0 or got != ["internal/cbm/vendored/grammars/go/parser.c", +if proc.returncode != 0 or got != ["internal/cbm/grammar_go.c", + "internal/cbm/grammar_json.c", + "internal/cbm/ts_runtime.c", "vendored/sqlite3/sqlite3.c"]: - failures.append(f"codeql (c): the retrace helper must list exactly the changed vendored " - f"C files that exist, got {got} (exit {proc.returncode})") + failures.append(f"codeql (c): the retrace helper must list the translation units for " + f"changed vendored code and wrappers, got {got} (exit {proc.returncode})") for label, args in (("an unreadable change list", ["--changed", str(work / "missing.txt")]), ("no pull request", [])): listed.write_text("stale\n", encoding="utf-8")