Skip to content

Commit 14dcd61

Browse files
romanlutzCopilotrichlundeen
authored
MAINT Retire stale CodeQL runs (#3124)
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Richard Lundeen <137218279+richlundeen@users.noreply.github.com>
1 parent aace937 commit 14dcd61

5 files changed

Lines changed: 600 additions & 1 deletion

File tree

‎.github/workflows/codeql.yml‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ jobs:
2121
name: CodeQL (${{ matrix.language }})
2222
runs-on: ubuntu-latest
2323
permissions:
24-
actions: read
24+
# Merge-group uploads can cancel this run after verifying that its candidate expired.
25+
actions: write
2526
contents: read
2627
security-events: write
2728
strategy:
@@ -32,6 +33,7 @@ jobs:
3233
- name: Checkout
3334
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
3435
with:
36+
ref: ${{ github.sha }}
3537
persist-credentials: false
3638

3739
- name: Initialize CodeQL
@@ -41,6 +43,38 @@ jobs:
4143
build-mode: none
4244

4345
- name: Perform CodeQL Analysis
46+
id: analysis
4447
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
4548
with:
4649
category: "/language:${{ matrix.language }}"
50+
# A literal value is required by the action's failed-analysis diagnostic recovery.
51+
upload: failure-only
52+
post-processed-sarif-path: ${{ runner.temp }}/codeql-upload
53+
54+
- name: Publish CodeQL results
55+
if: github.event_name != 'merge_group'
56+
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
57+
with:
58+
sarif_file: ${{ runner.temp }}/codeql-upload/upload.sarif
59+
category: "/language:${{ matrix.language }}"
60+
61+
- name: Publish merge-group CodeQL results
62+
if: github.event_name == 'merge_group'
63+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
64+
timeout-minutes: 8
65+
env:
66+
ANALYSIS_OUTCOME: ${{ steps.analysis.outcome }}
67+
SARIF_FILE: ${{ runner.temp }}/codeql-upload/upload.sarif
68+
SARIF_CATEGORY: "/language:${{ matrix.language }}"
69+
with:
70+
retries: 0
71+
script: |
72+
const publish = require(require('node:path').resolve('build_scripts', 'codeql_merge_group.cjs'));
73+
await publish({
74+
github, context, core,
75+
sarifFile: process.env.SARIF_FILE,
76+
category: process.env.SARIF_CATEGORY,
77+
analysisOutcome: process.env.ANALYSIS_OUTCOME,
78+
runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
79+
checkout: process.env.GITHUB_WORKSPACE,
80+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
// @ts-check
4+
5+
const { readFile } = require("node:fs/promises");
6+
const { setTimeout: delay } = require("node:timers/promises");
7+
const { gzipSync } = require("node:zlib");
8+
const { pathToFileURL } = require("node:url");
9+
10+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
11+
function isObject(value) {
12+
return value !== null && typeof value === "object" && !Array.isArray(value);
13+
}
14+
15+
/** @param {unknown} value @returns {Record<string, unknown>} */
16+
function object(value) {
17+
if (!isObject(value)) throw new Error("Invalid GitHub API or SARIF object");
18+
return value;
19+
}
20+
21+
/**
22+
* @typedef {{repo: {owner: string, repo: string}, eventName: string, ref: string, sha: string,
23+
* runId: number, payload: {action?: string, merge_group?: {base_ref?: string, head_ref?: string, head_sha?: string}}}} Context
24+
* @typedef {{request: (route: string, options: Record<string, unknown>) =>
25+
* Promise<{status: number, data: unknown}>}} GitHub
26+
* @param {{github: GitHub, context: Context, core: {notice: (message: string) => void, info: (message: string) => void},
27+
* sarifFile: string, category: string, analysisOutcome: string, runAttempt: number,
28+
* checkout: string, sleep?: (milliseconds: number) => Promise<void>}} options
29+
*/
30+
module.exports = async function publish({
31+
github, context, core, sarifFile, category, analysisOutcome, runAttempt, checkout, sleep = delay
32+
}) {
33+
const group = context.payload.merge_group;
34+
const queuePrefix = `refs/heads/gh-readonly-queue/${group?.base_ref?.slice(11)}/`;
35+
if (analysisOutcome !== "success") throw new Error("Publishing requires successful CodeQL analysis");
36+
if (
37+
context.eventName !== "merge_group" || context.payload.action !== "checks_requested" ||
38+
!group?.base_ref?.startsWith("refs/heads/") || !context.ref.startsWith(queuePrefix) || context.ref === queuePrefix ||
39+
group.head_ref !== context.ref || group.head_sha !== context.sha || !/^[0-9a-f]{40}$/.test(context.sha) ||
40+
!Number.isSafeInteger(context.runId) || context.runId <= 0 || !Number.isSafeInteger(runAttempt) || runAttempt <= 0
41+
) {
42+
throw new Error("The merge-group context does not match the tested queue ref, SHA and run");
43+
}
44+
const sarif = await readFile(sarifFile);
45+
const document = object(JSON.parse(sarif.toString("utf8")));
46+
if (
47+
document.version !== "2.1.0" || !Array.isArray(document.runs) || !document.runs.length ||
48+
document.runs.some((value) => {
49+
const run = object(value);
50+
return object(object(run.tool).driver).name !== "CodeQL" ||
51+
object(run.automationDetails).id !== `${category}/`;
52+
})
53+
) {
54+
throw new Error("Expected prepared CodeQL SARIF matching the matrix language category");
55+
}
56+
const missingRef = `ref '${context.ref}' not found in this repository`;
57+
const runOptions = { run_id: context.runId };
58+
59+
/** @param {string} route @param {Record<string, unknown>} [options] @param {number} [status] */
60+
async function request(route, options = {}, status = 200) {
61+
const response = await github.request(route, {
62+
...context.repo, ...options,
63+
headers: { "X-GitHub-Api-Version": "2022-11-28" },
64+
request: { timeout: 30000 }
65+
});
66+
if (response.status !== status) throw new Error(`Unexpected HTTP ${response.status} for ${route}`);
67+
return response.data;
68+
}
69+
70+
async function currentSha() {
71+
const refs = await request("GET /repos/{owner}/{repo}/git/matching-refs/{ref}", { ref: context.ref.slice(5) });
72+
if (!Array.isArray(refs) || refs.some((value) => typeof object(value).ref !== "string")) {
73+
throw new Error("Invalid matching-refs response");
74+
}
75+
const matches = refs.map(object).filter((value) => value.ref === context.ref);
76+
if (!matches.length) return null;
77+
if (matches.length !== 1) throw new Error("Matching-refs returned multiple exact queue refs");
78+
const target = object(matches[0].object);
79+
if (target.type !== "commit" || typeof target.sha !== "string" || !/^[0-9a-f]{40}$/.test(target.sha)) {
80+
throw new Error("The queue ref does not point to a valid commit");
81+
}
82+
return target.sha;
83+
}
84+
85+
async function verifyRun() {
86+
const run = object(await request("GET /repos/{owner}/{repo}/actions/runs/{run_id}", runOptions));
87+
const expected = {
88+
id: context.runId, run_attempt: runAttempt, event: "merge_group", head_sha: context.sha,
89+
head_branch: context.ref.slice(11), path: ".github/workflows/codeql.yml"
90+
};
91+
if (Object.entries(expected).some(([key, value]) => run[key] !== value)) {
92+
throw new Error("Refusing to cancel a different workflow run or attempt");
93+
}
94+
if (run.status === "completed" && run.conclusion === "cancelled") throw new Error("CodeQL run is already cancelled");
95+
if (run.status !== "in_progress" || run.conclusion !== null) {
96+
throw new Error("Refusing to cancel a workflow run that is not in progress");
97+
}
98+
}
99+
100+
async function ensureLive() {
101+
const sha = await currentSha();
102+
if (sha === context.sha) return;
103+
await verifyRun();
104+
const result = object(await request(
105+
"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}/jobs",
106+
{ ...runOptions, attempt_number: runAttempt, per_page: 100 }
107+
));
108+
if (!Array.isArray(result.jobs) || !result.jobs.length || result.total_count !== result.jobs.length) {
109+
throw new Error("Cannot verify all CodeQL matrix jobs before cancellation");
110+
}
111+
for (const value of result.jobs) {
112+
const job = object(value);
113+
if (!Array.isArray(job.steps)) throw new Error("Invalid workflow job steps");
114+
const conclusions = [job.conclusion, ...job.steps.map((step) => object(step).conclusion)];
115+
if (conclusions.some((value) =>
116+
value != null && value !== "success" && value !== "skipped" && value !== "cancelled"
117+
)) {
118+
throw new Error("A CodeQL matrix job or step failed; refusing to replace failure with cancellation");
119+
}
120+
}
121+
core.notice(`Verified obsolete merge candidate ${context.ref}: ${sha === null ? "absent" : `now at ${sha}`}`);
122+
try {
123+
await request("POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel", runOptions, 202);
124+
} catch (error) {
125+
if (isObject(error) && error.status === 409) await verifyRun();
126+
throw error;
127+
}
128+
// Never finish green while waiting for the runner's cancellation signal.
129+
await sleep(60000);
130+
throw new Error("Cancellation accepted, but the runner did not stop within 60 seconds");
131+
}
132+
133+
await ensureLive();
134+
let accepted;
135+
try {
136+
accepted = object(await request("POST /repos/{owner}/{repo}/code-scanning/sarifs", {
137+
commit_sha: context.sha,
138+
ref: context.ref,
139+
sarif: gzipSync(sarif).toString("base64"),
140+
checkout_uri: pathToFileURL(checkout).href,
141+
tool_name: "CodeQL"
142+
}, 202));
143+
} catch (error) {
144+
if (
145+
isObject(error) && (error.status === 400 || error.status === 404) && isObject(error.response) &&
146+
isObject(error.response.data) && error.response.data.message === missingRef &&
147+
(error.response.data.errors == null ||
148+
(Array.isArray(error.response.data.errors) && !error.response.data.errors.length))
149+
) {
150+
await ensureLive();
151+
}
152+
throw error;
153+
}
154+
const sarifId = accepted.id;
155+
if (typeof sarifId !== "string" || !sarifId) throw new Error("SARIF upload accepted without a valid ID");
156+
for (const milliseconds of [5000, 10000, 20000, 40000, 80000]) {
157+
await sleep(milliseconds);
158+
const result = object(await request(
159+
"GET /repos/{owner}/{repo}/code-scanning/sarifs/{sarif_id}", { sarif_id: sarifId }
160+
));
161+
if (result.processing_status === "complete") {
162+
await ensureLive();
163+
core.info(`CodeQL SARIF ${sarifId} processed for ${context.ref} at ${context.sha}`);
164+
return sarifId;
165+
}
166+
if (result.processing_status === "failed") {
167+
if (Array.isArray(result.errors) && result.errors.length === 1 && result.errors[0] === missingRef) {
168+
await ensureLive();
169+
}
170+
throw new Error(`SARIF processing failed: ${JSON.stringify(result.errors)}`);
171+
}
172+
if (result.processing_status !== "pending") {
173+
throw new Error(`Invalid SARIF processing status: ${result.processing_status}`);
174+
}
175+
await ensureLive();
176+
}
177+
throw new Error("SARIF processing did not complete within five status checks");
178+
};

‎doc/contributing/4_running_tests.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,35 @@ server, including when the `litellm` or `all` extra is installed. Real adapter
3838
and outgoing-header checks run separately as
3939
[loopback integration tests](./6_integration_tests.md#litellm-loopback-integration-tests).
4040

41+
### CodeQL merge-group uploads
42+
43+
CodeQL still analyzes all three languages on PRs, merge groups, main/release pushes,
44+
manual runs and its weekly schedule. Merge groups publish the analyzer's prepared
45+
SARIF through the documented code-scanning API so an expired queue ref can be
46+
distinguished from a genuine upload failure. Categories and fingerprints are
47+
preserved; failed analysis retains CodeQL's native diagnostic upload.
48+
The merge-group helper uses the authenticated client and context supplied by the
49+
pinned `actions/github-script` action, with no separate HTTP client or Python setup.
50+
51+
Only a successful matching-refs response proving that the exact queue ref is absent
52+
or points to another SHA permits retirement. A missing-ref upload rejection is
53+
rechecked to cover deletion between verification and upload. Authentication,
54+
service, processing and analysis failures stay failures. Retirement cancels the
55+
verified current CodeQL run, never reports a skipped scan as success, and refuses
56+
cancellation when a matrix job has already failed. The CodeQL job needs
57+
`actions: write` for self-cancellation; workflow-wide permissions remain empty.
58+
Fork PRs use native CodeQL publishing and GitHub's read-only fork token policy.
59+
60+
Run the deterministic, offline regression coverage with Node.js installed.
61+
The pytest entry point runs the Node scenario tests and checks workflow wiring:
62+
63+
```bash
64+
uv run --frozen pytest tests/unit/build_scripts/test_codeql_merge_group.py -q
65+
```
66+
67+
The helper's scenarios can also run directly with
68+
`node --test tests/unit/build_scripts/test_codeql_merge_group.cjs`.
69+
4170
## Running a subset while iterating
4271

4372
For a narrower run, invoke `pytest` directly. You can invoke pytest if it's in your path or via python; either `pytest` or `python -m pytest`. For the following examples, we will use `pytest`.

0 commit comments

Comments
 (0)