| 1 | #!/usr/bin/env node |
| 2 | // Post a GitHub Check Run for the exact commit a CNB pipeline is building, |
| 3 | // authenticating as the codewhale-cnb-bridge GitHub App. |
| 4 | // |
| 5 | // Secret custody: the App credentials live in the CNB KeyStore repo |
| 6 | // codewhale.net/codewhale-ci-secrets (github-bridge.yml) and are injected as |
| 7 | // environment variables via `imports:` in .cnb.yml |
| 8 | // (https://docs.cnb.cool/en/repo/secret.html). This script reads them from the |
| 9 | // environment and never logs them. Accepted variable names (first match wins): |
| 10 | // app id: GITHUB_APP_ID, GH_APP_ID, APP_ID |
| 11 | // installation id: GITHUB_APP_INSTALLATION_ID, GH_APP_INSTALLATION_ID, INSTALLATION_ID |
| 12 | // private key: GITHUB_APP_PRIVATE_KEY, GH_APP_PRIVATE_KEY, PRIVATE_KEY |
| 13 | // The private key may be a raw PEM, a PEM with escaped \n, or base64-encoded. |
| 14 | // |
| 15 | // Usage: |
| 16 | // node scripts/ci/cnb-github-checkrun.mjs \ |
| 17 | // --name "linux rust gates -cnb" --sha <40-hex> \ |
| 18 | // --status completed --conclusion success \ |
| 19 | // --details-url <url> --summary <text> |
| 20 | import crypto from "node:crypto"; |
| 21 | |
| 22 | const GITHUB_API = process.env.GITHUB_API_BASE || "https://api.github.com"; |
| 23 | const REPO = process.env.GITHUB_REPOSITORY || "Hmbown/CodeWhale"; |
| 24 | const USER_AGENT = "codewhale-cnb-bridge"; |
| 25 | |
| 26 | function fail(message) { |
| 27 | console.error(`cnb-github-checkrun: ${message}`); |
| 28 | process.exit(1); |
| 29 | } |
| 30 | |
| 31 | function envAny(...names) { |
| 32 | for (const name of names) { |
| 33 | const value = process.env[name]; |
| 34 | if (value && value.trim()) return value.trim(); |
| 35 | } |
| 36 | return ""; |
| 37 | } |
| 38 | |
| 39 | function parseArgs(argv) { |
| 40 | const args = {}; |
| 41 | for (let i = 0; i < argv.length; i += 1) { |
| 42 | const token = argv[i]; |
| 43 | if (!token.startsWith("--")) fail(`unexpected argument ${JSON.stringify(token)}`); |
| 44 | const key = token.slice(2); |
| 45 | const value = argv[i + 1]; |
| 46 | if (value === undefined || value.startsWith("--")) fail(`--${key} requires a value`); |
| 47 | args[key] = value; |
| 48 | i += 1; |
| 49 | } |
| 50 | return args; |
| 51 | } |
| 52 | |
| 53 | function normalizePrivateKey(raw) { |
| 54 | const withNewlines = raw.replace(/\\n/g, "\n"); |
| 55 | if (withNewlines.includes("BEGIN")) return withNewlines; |
| 56 | const decoded = Buffer.from(raw, "base64").toString("utf8"); |
| 57 | if (decoded.includes("BEGIN")) return decoded; |
| 58 | fail("private key does not look like a PEM (raw, escaped, or base64)"); |
| 59 | } |
| 60 | |
| 61 | function mintAppJwt(appId, privateKey) { |
| 62 | const now = Math.floor(Date.now() / 1000); |
| 63 | const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); |
| 64 | const header = encode({ alg: "RS256", typ: "JWT" }); |
| 65 | const payload = encode({ iat: now - 60, exp: now + 540, iss: appId }); |
| 66 | const unsigned = `${header}.${payload}`; |
| 67 | const signature = crypto.sign("sha256", Buffer.from(unsigned), privateKey).toString("base64url"); |
| 68 | return `${unsigned}.${signature}`; |
| 69 | } |
| 70 | |
| 71 | async function githubApi(path, token, method, body) { |
| 72 | const response = await fetch(`${GITHUB_API}${path}`, { |
| 73 | method, |
| 74 | headers: { |
| 75 | Authorization: `Bearer ${token}`, |
| 76 | Accept: "application/vnd.github+json", |
| 77 | "X-GitHub-Api-Version": "2022-11-28", |
| 78 | "User-Agent": USER_AGENT, |
| 79 | }, |
| 80 | body: body ? JSON.stringify(body) : undefined, |
| 81 | }); |
| 82 | if (!response.ok) { |
| 83 | // GitHub error bodies never contain our credentials; cap the log line anyway. |
| 84 | const text = (await response.text()).slice(0, 500); |
| 85 | fail(`${method} ${path} -> HTTP ${response.status}: ${text}`); |
| 86 | } |
| 87 | return response.json(); |
| 88 | } |
| 89 | |
| 90 | async function main() { |
| 91 | const args = parseArgs(process.argv.slice(2)); |
| 92 | const name = args.name || fail("--name is required"); |
| 93 | const sha = args.sha || fail("--sha is required"); |
| 94 | if (!/^[0-9a-f]{40}$/.test(sha)) { |
| 95 | fail(`--sha must be a 40-character lowercase hex commit, got ${JSON.stringify(sha)}`); |
| 96 | } |
| 97 | const status = args.status || "completed"; |
| 98 | if (!["queued", "in_progress", "completed"].includes(status)) { |
| 99 | fail(`--status must be queued|in_progress|completed, got ${JSON.stringify(status)}`); |
| 100 | } |
| 101 | const conclusions = ["success", "failure", "cancelled", "neutral", "skipped", "timed_out"]; |
| 102 | const conclusion = args.conclusion || ""; |
| 103 | if (status === "completed" && !conclusions.includes(conclusion)) { |
| 104 | fail(`--conclusion must be one of ${conclusions.join("|")} when --status completed`); |
| 105 | } |
| 106 | |
| 107 | const appId = envAny("GITHUB_APP_ID", "GH_APP_ID", "APP_ID") || |
| 108 | fail("GitHub App id env var missing (expected GITHUB_APP_ID)"); |
| 109 | const installationId = envAny("GITHUB_APP_INSTALLATION_ID", "GH_APP_INSTALLATION_ID", "INSTALLATION_ID") || |
| 110 | fail("GitHub App installation id env var missing (expected GITHUB_APP_INSTALLATION_ID)"); |
| 111 | const privateKey = normalizePrivateKey( |
| 112 | envAny("GITHUB_APP_PRIVATE_KEY", "GH_APP_PRIVATE_KEY", "PRIVATE_KEY") || |
| 113 | fail("GitHub App private key env var missing (expected GITHUB_APP_PRIVATE_KEY)"), |
| 114 | ); |
| 115 | |
| 116 | const jwt = mintAppJwt(appId, privateKey); |
| 117 | const installation = await githubApi( |
| 118 | `/app/installations/${encodeURIComponent(installationId)}/access_tokens`, |
| 119 | jwt, |
| 120 | "POST", |
| 121 | ); |
| 122 | if (!installation.token) fail("installation token response had no token field"); |
| 123 | |
| 124 | const now = new Date().toISOString(); |
| 125 | const checkRun = { |
| 126 | name, |
| 127 | head_sha: sha, |
| 128 | status, |
| 129 | output: { |
| 130 | title: args.title || `${name}: ${status === "completed" ? conclusion : status}`, |
| 131 | summary: args.summary || "", |
| 132 | }, |
| 133 | }; |
| 134 | if (args["details-url"]) checkRun.details_url = args["details-url"]; |
| 135 | if (status === "completed") { |
| 136 | checkRun.conclusion = conclusion; |
| 137 | checkRun.completed_at = now; |
| 138 | } else { |
| 139 | checkRun.started_at = now; |
| 140 | } |
| 141 | |
| 142 | const created = await githubApi(`/repos/${REPO}/check-runs`, installation.token, "POST", checkRun); |
| 143 | // Receipt line: id, conclusion, and URL only — never any credential material. |
| 144 | console.log(`check run ${created.id} ${status}${conclusion ? `/${conclusion}` : ""} ${created.html_url}`); |
| 145 | } |
| 146 | |
| 147 | main().catch((error) => fail(error && error.message ? error.message : String(error))); |
| 148 |