|
| 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 | +}; |
0 commit comments