// Contains utility functions for various scripts, including: // CI, running tests, and code generation. import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from "node:child_process"; import { createHash, createHmac } from "node:crypto"; import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync, } from "node:fs"; import { connect } from "node:net"; import { hostname, homedir as nodeHomedir, tmpdir as nodeTmpdir, release, userInfo } from "node:os"; import { basename, dirname, join, relative, resolve } from "node:path"; import { normalize as normalizeWindows } from "node:path/win32"; export const isWindows = process.platform === "win32"; export const isMacOS = process.platform === "darwin"; // Node built for Termux/bionic reports "android"; CI models that as linux + abi=android. export const isAndroid = process.platform === "android"; export const isLinux = process.platform === "linux" || isAndroid; export const isFreeBSD = process.platform === "freebsd"; export const isPosix = isMacOS || isLinux || isFreeBSD; export const isX64 = process.arch === "x64"; /** * @param {string} name * @param {boolean} [required] * @returns {string} */ export function getEnv(name, required = true) { const value = process.env[name]; if (required && !value) { throw new Error(`Environment variable is missing: ${name}`); } return value; } export const isBuildkite = getEnv("BUILDKITE", false) === "true"; export const isGithubAction = getEnv("GITHUB_ACTIONS", false) === "true"; export const isCI = getEnv("CI", false) === "true" || isBuildkite || isGithubAction; export const isDebug = getEnv("DEBUG", false) === "1"; /** * @param {string} name * @param {object} [options] * @param {boolean} [options.required] * @param {boolean} [options.redact] * @returns {string} */ export function getSecret(name, options = { required: true, redact: true }) { const value = getEnv(name, false); if (value) { return value; } if (isBuildkite) { const command = ["buildkite-agent", "secret", "get", name]; if (options["redact"] === false) { command.push("--skip-redaction"); } const { error, stdout } = spawnSync(command); const secret = stdout.trim(); if (error || !secret) { const orgId = getEnv("BUILDKITE_ORGANIZATION_SLUG", false); const clusterId = getEnv("BUILDKITE_CLUSTER_ID", false); let hint; if (orgId && clusterId) { hint = `https://buildkite.com/organizations/${orgId}/clusters/${clusterId}/secrets`; } else { hint = "https://buildkite.com/docs/pipelines/buildkite-secrets"; } throw new Error(`Secret not found: ${name} (hint: go to ${hint} and create a secret)`, { cause: error }); } setEnv(name, secret); return secret; } return getEnv(name, options["required"]); } /** * @param {...unknown} args */ export function debugLog(...args) { if (isDebug) { console.log(...args); } } /** * @param {string} name * @param {string | undefined} value */ export function setEnv(name, value) { process.env[name] = value; if (isGithubAction && !/^GITHUB_/i.test(name)) { const envFilePath = process.env["GITHUB_ENV"]; if (envFilePath) { const delimeter = Math.random().toString(36).substring(2, 15); const content = `${name}<<${delimeter}\n${value}\n${delimeter}\n`; appendFileSync(outputPath, content); } } } /** * @typedef {object} SpawnOptions * @property {string} [cwd] * @property {number} [timeout] * @property {Record} [env] * @property {boolean | ((error: Error) => boolean)} [throwOnError] * @property {(error: Error) => boolean} [retryOnError] * @property {string} [stdin] * @property {boolean} [privileged] */ /** * @typedef {object} SpawnResult * @property {number} exitCode * @property {number} [signalCode] * @property {string} stdout * @property {string} stderr * @property {Error} [error] */ /** * @param {TemplateStringsArray} strings * @param {...any} values * @returns {string[]} */ export function $(strings, ...values) { const result = []; for (let i = 0; i < strings.length; i++) { result.push(...strings[i].trim().split(/\s+/).filter(Boolean)); if (i < values.length) { const value = values[i]; if (Array.isArray(value)) { result.push(...value); } else if (typeof value === "string") { if (result.at(-1)?.endsWith("=")) { result[result.length - 1] += value; } else { result.push(value); } } } } return result; } /** * @param {string[]} command * @param {SpawnOptions} options */ function parseCommand(command, options) { if (options?.privileged) { return [...getPrivilegedCommand(), ...command]; } return command; } /** @type {string[] | undefined} */ let priviledgedCommand; /** * @returns {string[]} */ function getPrivilegedCommand() { if (typeof priviledgedCommand !== "undefined") { return priviledgedCommand; } if (isWindows) { return (priviledgedCommand = []); } const sudo = ["sudo", "-n"]; const { error: sudoError } = spawnSync([...sudo, "true"]); if (!sudoError) { return (priviledgedCommand = sudo); } const su = ["su", "-s", "sh", "root", "-c"]; const { error: suError } = spawnSync([...su, "true"]); if (!suError) { return (priviledgedCommand = su); } const doas = ["doas", "-u", "root"]; const { error: doasError } = spawnSync([...doas, "true"]); if (!doasError) { return (priviledgedCommand = doas); } return (priviledgedCommand = []); } /** @type {boolean | undefined} */ let privileged; /** * @returns {boolean} */ export function isPrivileged() { if (typeof privileged !== "undefined") { return privileged; } const command = getPrivilegedCommand(); if (command.length) { const { error } = spawnSync(command); privileged = !error; } else { privileged = false; } return privileged; } /** * @param {string[]} command * @param {SpawnOptions} options * @returns {Promise} */ export async function spawn(command, options = {}) { const [cmd, ...args] = parseCommand(command, options); debugLog("$", cmd, ...args); const stdin = options["stdin"]; const spawnOptions = { cwd: options["cwd"] ?? process.cwd(), timeout: options["timeout"] ?? undefined, env: options["env"] ?? undefined, stdio: stdin === "inherit" ? "inherit" : [stdin ? "pipe" : "ignore", "pipe", "pipe"], ...options, }; let exitCode = 1; let signalCode; let stdout = ""; let stderr = ""; let error; const result = new Promise((resolve, reject) => { const subprocess = nodeSpawn(cmd, args, spawnOptions); if (typeof stdin !== "undefined") { subprocess.stdin?.on("error", error => { if (error.code !== "EPIPE") { reject(error); } }); subprocess.stdin?.write(stdin); subprocess.stdin?.end(); } subprocess.stdout?.on("data", chunk => { stdout += chunk; }); subprocess.stderr?.on("data", chunk => { stderr += chunk; }); subprocess.on("error", error => reject(error)); subprocess.on("exit", (code, signal) => { exitCode = code; signalCode = signal; resolve(); }); }); try { await result; } catch (cause) { error = cause; } if (exitCode !== 0 && isWindows) { const exitReason = getWindowsExitReason(exitCode); if (exitReason) { signalCode = exitReason; } } if (error || signalCode || exitCode !== 0) { const description = command.map(arg => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)).join(" "); const cause = error || stderr.trim() || stdout.trim() || undefined; if (signalCode) { error = new Error(`Command killed with ${signalCode}: ${description}`, { cause }); } else { error = new Error(`Command exited with code ${exitCode}: ${description}`, { cause }); } } if (error) { const retryOnError = options["retryOnError"]; if (typeof retryOnError === "function") { if (retryOnError(error)) { return spawn(command, options); } } const throwOnError = options["throwOnError"]; if (typeof throwOnError === "function") { if (throwOnError(error)) { throw error; } } else if (throwOnError) { throw error; } } return { exitCode, signalCode, stdout, stderr, error, }; } /** * @param {string[]} command * @param {SpawnOptions} options * @returns {Promise} */ export async function spawnSafe(command, options = {}) { return spawn(command, { throwOnError: true, ...options }); } /** * @param {string[]} command * @param {SpawnOptions} options * @returns {SpawnResult} */ export function spawnSync(command, options = {}) { const [cmd, ...args] = parseCommand(command, options); debugLog("$", cmd, ...args); const stdin = options["stdin"]; const spawnOptions = { cwd: options["cwd"] ?? process.cwd(), timeout: options["timeout"] ?? undefined, env: options["env"] ?? undefined, stdio: stdin === "inherit" ? "inherit" : [typeof stdin === "undefined" ? "ignore" : "pipe", "pipe", "pipe"], input: stdin, ...options, }; let exitCode = 1; let signalCode; let stdout = ""; let stderr = ""; let error; let result; try { result = nodeSpawnSync(cmd, args, spawnOptions); } catch (error) { result = { error }; } const { error: spawnError, status, signal, stdout: stdoutBuffer, stderr: stderrBuffer } = result; if (spawnError) { error = spawnError; } else { exitCode = status ?? 1; signalCode = signal || undefined; stdout = stdoutBuffer?.toString?.() ?? ""; stderr = stderrBuffer?.toString?.() ?? ""; } if (exitCode !== 0 && isWindows) { const exitReason = getWindowsExitReason(exitCode); if (exitReason) { signalCode = exitReason; } } if (error || signalCode || exitCode !== 0) { const description = command.map(arg => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)).join(" "); const cause = error || stderr?.trim() || stdout?.trim() || undefined; if (signalCode) { error = new Error(`Command killed with ${signalCode}: ${description}`, { cause }); } else { error = new Error(`Command exited with code ${exitCode}: ${description}`, { cause }); } } if (error) { const retryOnError = options["retryOnError"]; if (typeof retryOnError === "function") { if (retryOnError(error)) { return spawn(command, options); } } const throwOnError = options["throwOnError"]; if (typeof throwOnError === "function") { if (throwOnError(error)) { throw error; } } else if (throwOnError) { throw error; } } return { exitCode, signalCode, stdout, stderr, error, }; } /** * @param {string[]} command * @param {SpawnOptions} options * @returns {SpawnResult} */ export function spawnSyncSafe(command, options = {}) { return spawnSync(command, { throwOnError: true, ...options }); } /** * @param {number} exitCode * @returns {string | undefined} */ export function getWindowsExitReason(exitCode) { const windowsKitPath = "C:\\Program Files (x86)\\Windows Kits"; if (!existsSync(windowsKitPath)) { return; } const windowsKitPaths = readdirSync(windowsKitPath) .filter(filename => isFinite(parseInt(filename))) .sort((a, b) => parseInt(b) - parseInt(a)); let ntStatusPath; for (const windowsKitPath of windowsKitPaths) { const includePath = `${windowsKitPath}\\Include`; if (!existsSync(includePath)) { continue; } const windowsSdkPaths = readdirSync(includePath).sort(); for (const windowsSdkPath of windowsSdkPaths) { const statusPath = `${includePath}\\${windowsSdkPath}\\shared\\ntstatus.h`; if (existsSync(statusPath)) { ntStatusPath = statusPath; break; } } } if (!ntStatusPath) { return; } const nthStatus = readFile(ntStatusPath, { cache: true }); const match = nthStatus.match(new RegExp(`(STATUS_\\w+).*0x${exitCode?.toString(16)}`, "i")); if (match) { const [, exitReason] = match; return exitReason; } } /** * @param {string | URL} url * @returns {URL | undefined} */ export function parseGitUrl(url) { const string = typeof url === "string" ? url : url.toString(); const githubUrl = getEnv("GITHUB_SERVER_URL", false) || "https://github.com"; if (/^git@github\.com:/.test(string)) { return new URL(string.slice(15).replace(/\.git$/, ""), githubUrl); } if (/^https:\/\/github\.com\//.test(string)) { return new URL(string.slice(19).replace(/\.git$/, ""), githubUrl); } } /** * @param {string | URL} url * @returns {string | undefined} */ export function parseGitRepository(url) { const parsed = parseGitUrl(url); if (parsed) { const { hostname, pathname } = parsed; if (hostname == "github.com") { return pathname.slice(1); } } } /** * @param {string} [cwd] * @returns {URL | undefined} */ export function getRepositoryUrl(cwd) { if (!cwd) { if (isBuildkite) { const repository = getEnv("BUILDKITE_REPO", false); if (repository) { return parseGitUrl(repository); } } if (isGithubAction) { const serverUrl = getEnv("GITHUB_SERVER_URL", false) || "https://github.com"; const repository = getEnv("GITHUB_REPOSITORY", false); if (serverUrl && repository) { return parseGitUrl(new URL(repository, serverUrl)); } } } const { error, stdout } = spawnSync(["git", "remote", "get-url", "origin"], { cwd }); if (!error) { return parseGitUrl(stdout.trim()); } } /** * @param {string} [cwd] * @returns {string | undefined} */ export function getRepository(cwd) { if (!cwd) { if (isGithubAction) { const repository = getEnv("GITHUB_REPOSITORY", false); if (repository) { return repository; } } } const url = getRepositoryUrl(cwd); if (url) { return parseGitRepository(url); } } /** * @param {string} [cwd] * @returns {string | undefined} */ export function getCommit(cwd) { if (!cwd) { if (isBuildkite) { const commit = getEnv("BUILDKITE_COMMIT", false); if (commit) { return commit; } } if (isGithubAction) { const commit = getEnv("GITHUB_SHA", false); if (commit) { return commit; } } } const { error, stdout } = spawnSync(["git", "rev-parse", "HEAD"], { cwd }); if (!error) { return stdout.trim(); } } /** * @param {string} [cwd] * @returns {string | undefined} */ export function getCommitMessage(cwd) { if (!cwd) { if (isBuildkite) { const message = getEnv("BUILDKITE_MESSAGE", false); if (message) { return message; } } } const { error, stdout } = spawnSync(["git", "log", "-1", "--pretty=%B"], { cwd }); if (!error) { return stdout.trim(); } } /** * @param {string} [cwd] * @returns {string | undefined} */ export function getBranch(cwd) { if (!cwd) { if (isBuildkite) { const branch = getEnv("BUILDKITE_BRANCH", false); if (branch) { return branch; } } if (isGithubAction) { const ref = getEnv("GITHUB_REF_NAME", false); if (ref) { return ref; } } } const { error, stdout } = spawnSync(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd }); if (!error) { return stdout.trim(); } } /** * @param {string} [cwd] * @returns {string | undefined} */ export function getMainBranch(cwd) { if (!cwd) { if (isBuildkite) { const branch = getEnv("BUILDKITE_PIPELINE_DEFAULT_BRANCH", false); if (branch) { return branch; } } if (isGithubAction) { const headRef = getEnv("GITHUB_HEAD_REF", false); if (headRef) { return headRef; } } } const { error, stdout } = spawnSync(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], { cwd }); if (!error) { return stdout.trim().replace("refs/remotes/origin/", ""); } } /** * @param {string} [cwd] * @returns {boolean} */ export function isMainBranch(cwd) { return !isFork(cwd) && getBranch(cwd) === getMainBranch(cwd); } /** * @returns {boolean} */ export function isPullRequest() { if (isBuildkite) { return !isNaN(parseInt(getEnv("BUILDKITE_PULL_REQUEST", false))); } if (isGithubAction) { return /pull_request|merge_group/.test(getEnv("GITHUB_EVENT_NAME", false)); } return false; } /** * @returns {number | undefined} */ export function getPullRequest() { if (isBuildkite) { const pullRequest = getEnv("BUILDKITE_PULL_REQUEST", false); if (pullRequest) { return parseInt(pullRequest); } } if (isGithubAction) { const eventPath = getEnv("GITHUB_EVENT_PATH", false); if (eventPath && existsSync(eventPath)) { const event = JSON.parse(readFile(eventPath, { cache: true })); const pullRequest = event["pull_request"]; if (pullRequest) { return parseInt(pullRequest["number"]); } } } } /** * @returns {string | undefined} */ export function getTargetBranch() { if (isPullRequest()) { if (isBuildkite) { return getEnv("BUILDKITE_PULL_REQUEST_BASE_BRANCH", false); } if (isGithubAction) { return getEnv("GITHUB_BASE_REF", false); } } } /** * @returns {boolean} */ export function isFork() { if (isBuildkite) { const repository = getEnv("BUILDKITE_PULL_REQUEST_REPO", false); return !!repository && repository !== getEnv("BUILDKITE_REPO", false); } if (isGithubAction) { const eventPath = getEnv("GITHUB_EVENT_PATH", false); if (eventPath && existsSync(eventPath)) { const event = JSON.parse(readFile(eventPath, { cache: true })); const pullRequest = event["pull_request"]; if (pullRequest) { return !!pullRequest["head"]["repo"]["fork"]; } } } return false; } /** * @param {string} [cwd] * @returns {boolean} */ export function isMergeQueue(cwd) { return /^gh-readonly-queue/.test(getBranch(cwd)); } /** * @returns {string | undefined} */ export function getGithubToken() { const cachedToken = getSecret("GITHUB_TOKEN", { required: false }); if (typeof cachedToken === "string" || !which("gh")) { return cachedToken || undefined; } const { error, stdout } = spawnSync(["gh", "auth", "token"]); const token = error ? "" : stdout.trim(); setEnv("GITHUB_TOKEN", token); return token || undefined; } /** * @typedef {object} CurlOptions * @property {string} [method] * @property {string} [body] * @property {Record} [headers] * @property {number} [timeout] * @property {boolean} [cache] * @property {number} [retries] * @property {boolean} [json] * @property {boolean} [arrayBuffer] * @property {string} [filename] */ /** * @typedef {object} CurlResult * @property {number} status * @property {string} statusText * @property {Error | undefined} error * @property {any} body */ /** @type {Record} */ let cachedResults; /** * @param {string} url * @param {CurlOptions} [options] * @returns {Promise} */ export async function curl(url, options = {}) { let { hostname, href } = new URL(url); let method = options["method"] || "GET"; let input = options["body"]; let headers = options["headers"] || {}; let retries = options["retries"] || 3; let json = options["json"]; let arrayBuffer = options["arrayBuffer"]; let filename = options["filename"]; let cacheKey; let cache = options["cache"]; if (cache) { cacheKey = `${method} ${href}`; if (cachedResults?.[cacheKey]) { return cachedResults[cacheKey]; } } if (typeof headers["Authorization"] === "undefined") { if (hostname === "api.github.com" || hostname === "uploads.github.com") { const githubToken = getGithubToken(); if (githubToken) { headers["Authorization"] = `Bearer ${githubToken}`; } } } let status; let statusText; let body; let error; for (let i = 0; i < retries; i++) { if (i > 0) { await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); } let response; try { response = await fetch(href, { method, headers, body: input }); } catch (cause) { debugLog("$", "curl", href, "-> error"); error = new Error(`Fetch failed: ${method} ${url}`, { cause }); continue; } status = response["status"]; statusText = response["statusText"]; debugLog("$", "curl", href, "->", status, statusText); const ok = response["ok"]; try { if (filename && ok) { const buffer = await response.arrayBuffer(); writeFile(filename, new Uint8Array(buffer)); } else if (arrayBuffer && ok) { body = await response.arrayBuffer(); } else if (json && ok) { body = await response.json(); } else { body = await response.text(); } } catch (cause) { error = new Error(`Fetch failed: ${method} ${url}`, { cause }); continue; } if (response["ok"]) { break; } error = new Error(`Fetch failed: ${method} ${url}: ${status} ${statusText}`, { cause: body }); if (status === 400 || status === 404 || status === 422) { break; } } if (cacheKey) { cachedResults ||= {}; cachedResults[cacheKey] = { status, statusText, error, body }; } return { status, statusText, error, body, }; } /** * @param {string} url * @param {CurlOptions} options * @returns {Promise} */ export async function curlSafe(url, options) { const result = await curl(url, options); const { error, body } = result; if (error) { throw error; } return body; } /** @type {Record | undefined} */ let cachedFiles; /** * @param {string} filename * @param {object} [options] * @param {boolean} [options.cache] * @returns {string} */ export function readFile(filename, options = {}) { const absolutePath = resolve(filename); if (options["cache"]) { if (cachedFiles?.[absolutePath]) { return cachedFiles[absolutePath]; } } debugLog("$", "cat", absolutePath); let content; try { content = readFileSync(absolutePath, "utf-8"); } catch (cause) { throw new Error(`Read failed: ${absolutePath}`, { cause }); } if (options["cache"]) { cachedFiles ||= {}; cachedFiles[absolutePath] = content; } return content; } /** * @param {string} path * @param {number} mode */ export function chmod(path, mode) { debugLog("$", "chmod", path, mode); chmodSync(path, mode); } /** * @param {string} filename * @param {string | Buffer} content * @param {object} [options] * @param {number} [options.mode] */ export function writeFile(filename, content, options) { mkdir(dirname(filename)); debugLog("$", "touch", filename); writeFileSync(filename, content); if (options?.mode) { chmod(filename, options.mode); } } /** * @param {string} source * @param {string} destination * @param {object} [options] * @param {number} [options.mode] */ export function copyFile(source, destination, options) { mkdir(dirname(destination)); debugLog("$", "cp", source, destination); copyFileSync(source, destination); if (options?.mode) { chmod(destination, options.mode); } } /** * @param {string} path * @param {object} [options] * @param {number} [options.mode] */ export function mkdir(path, options = {}) { if (existsSync(path)) { return; } debugLog("$", "mkdir", path); mkdirSync(path, { ...options, recursive: true }); } /** * @param {string} path */ export function rm(path) { let stats; try { stats = statSync(path); } catch { return; } if (stats?.isDirectory()) { debugLog("$", "rm", "-rf", path); rmSync(path, { recursive: true, force: true }); } else { debugLog("$", "rm", "-f", path); rmSync(path, { force: true }); } } /** * @param {string | string[]} command * @param {object} [options] * @param {boolean} [options.required] * @returns {string | undefined} */ export function which(command, options = {}) { const commands = Array.isArray(command) ? command : [command]; const executables = isWindows ? commands.flatMap(name => [name, `${name}.exe`, `${name}.cmd`]) : commands; const path = getEnv("PATH", false) || ""; const binPaths = path.split(isWindows ? ";" : ":"); for (const binPath of binPaths) { for (const executable of executables) { const executablePath = join(binPath, executable); if (existsSync(executablePath)) { return executablePath; } } } if (options["required"]) { const description = commands.join(" or "); throw new Error(`Command not found: ${description}`); } } /** * @returns {string | undefined} */ export function getBuildId() { if (isBuildkite) { return getEnv("BUILDKITE_BUILD_ID"); } if (isGithubAction) { return getEnv("GITHUB_RUN_ID"); } } /** * @returns {number | undefined} */ export function getBuildNumber() { if (isBuildkite) { return parseInt(getEnv("BUILDKITE_BUILD_NUMBER")); } if (isGithubAction) { return parseInt(getEnv("GITHUB_RUN_ID")); } } /** * @returns {URL | undefined} */ export function getBuildUrl() { if (isBuildkite) { const buildUrl = getEnv("BUILDKITE_BUILD_URL"); const jobId = getEnv("BUILDKITE_JOB_ID"); return new URL(`#${jobId}`, buildUrl); } if (isGithubAction) { const baseUrl = getEnv("GITHUB_SERVER_URL", false) || "https://github.com"; const repository = getEnv("GITHUB_REPOSITORY"); const runId = getEnv("GITHUB_RUN_ID"); return new URL(`${repository}/actions/runs/${runId}`, baseUrl); } } /** * @returns {string | undefined} */ export function getBuildLabel() { if (isBuildkite) { const label = getEnv("BUILDKITE_LABEL", false) || getEnv("BUILDKITE_GROUP_LABEL", false); if (label) { return label; } } if (isGithubAction) { const label = getEnv("GITHUB_WORKFLOW", false); if (label) { return label; } } } /** * @returns {boolean | undefined} */ export function isBuildManual() { if (isBuildkite) { const buildSource = getEnv("BUILDKITE_SOURCE", false); if (buildSource) { const buildId = getEnv("BUILDKITE_REBUILT_FROM_BUILD_ID", false); return buildSource === "ui" && !buildId; } } } /** * @param {string} [os] * @returns {number} */ export function getBootstrapVersion(os) { const scriptPath = join( import.meta.dirname, os === "windows" || (!os && isWindows) ? "bootstrap.ps1" : "bootstrap.sh", ); const scriptContent = readFile(scriptPath, { cache: true }); const match = /# Version: (\d+)/.exec(scriptContent); if (match) { const [, version] = match; return parseInt(version); } return 0; } /** * @param {string} [filename] * @param {number} [line] * @returns {URL | undefined} */ export function getFileUrl(filename, line) { let cwd; if (filename?.startsWith("vendor")) { const parentPath = resolve(dirname(filename)); const { error, stdout } = spawnSync(["git", "rev-parse", "--show-toplevel"], { cwd: parentPath }); if (error) { return; } cwd = stdout.trim(); } const baseUrl = getRepositoryUrl(cwd); if (!filename) { return baseUrl; } const filePath = (cwd ? relative(cwd, filename) : filename).replace(/\\/g, "/"); const commit = getCommit(cwd); const url = new URL(`blob/${commit}/${filePath}`, `${baseUrl}/`).toString(); if (typeof line !== "undefined") { return new URL(`#L${line}`, url); } return url; } /** * @typedef {object} BuildkiteBuild * @property {string} id * @property {string} commit_id * @property {string} branch_name */ /** * @returns {Promise} */ export async function getLastSuccessfulBuild() { if (isBuildkite) { let depth = 0; let url = getBuildUrl(); if (url) { url.hash = ""; } while (url) { const { error, body } = await curl(`${url}.json`, { json: true, cache: true }); if (error) { return; } const { state, prev_branch_build: previousBuild, steps } = body; if (depth++) { if (state === "failed" || state === "passed" || state === "canceled") { const buildSteps = steps.filter(({ label }) => label.endsWith("build-bun")); if (buildSteps.length) { if (buildSteps.every(({ outcome }) => outcome === "passed")) { return body; } return; } } } if (!previousBuild) { return; } url = new URL(previousBuild["url"], url); } } } /** * @param {string} filename Absolute path to file to upload */ export async function uploadArtifact(filename) { if (isBuildkite) { await spawnSafe(["buildkite-agent", "artifact", "upload", basename(filename)], { cwd: dirname(filename), stdio: "inherit", }); } else { console.warn(`not in buildkite. artifact ${filename} not uploaded.`); } } /** * @param {string} string * @returns {string} */ export function stripAnsi(string) { return string.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, ""); } /** * @param {string} string * @returns {string} */ export function unescapeGitHubAction(string) { return string.replace(/%25/g, "%").replace(/%0D/g, "\r").replace(/%0A/g, "\n"); } /** * @param {string} string * @returns {string} */ export function escapeHtml(string) { return string .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") .replace(/`/g, "`"); } /** * @param {string} string * @returns {string} */ export function escapeCodeBlock(string) { return string.replace(/`/g, "\\`"); } /** * @param {string} string * @returns {string} */ export function escapePowershell(string) { return string.replace(/'/g, "''").replace(/`/g, "``"); } /** * @returns {string} */ export function homedir() { return nodeHomedir(); } /** * @returns {string} */ export function tmpdir() { if (isWindows) { for (const key of ["TMPDIR", "TEMP", "TEMPDIR", "TMP", "RUNNER_TEMP"]) { const tmpdir = getEnv(key, false); if (!tmpdir || /cygwin|cygdrive/i.test(tmpdir) || !/^[a-z]/i.test(tmpdir)) { continue; } return normalizeWindows(tmpdir); } const appData = process.env["LOCALAPPDATA"]; if (appData) { const appDataTemp = join(appData, "Temp"); if (existsSync(appDataTemp)) { return appDataTemp; } } } if (isMacOS || isLinux) { if (existsSync("/tmp")) { return "/tmp"; } } return nodeTmpdir(); } /** * @param {string} [prefix] * @param {string} [filename] * @returns {string} */ export function mkdtemp(prefix, filename) { const tmpPath = mkdtempSync(join(tmpdir(), prefix || "bun-")); return filename ? join(tmpPath, filename) : tmpPath; } /** * @param {string} filename * @param {string} [output] * @returns {Promise} */ export async function unzip(filename, output) { const destination = output || mkdtempSync(join(tmpdir(), "unzip-")); if (isWindows) { const command = `Expand-Archive -Force -LiteralPath "${escapePowershell(filename)}" -DestinationPath "${escapePowershell(destination)}"`; await spawnSafe(["powershell", "-Command", command]); } else { await spawnSafe(["unzip", "-o", filename, "-d", destination]); } return destination; } /** * @param {string} value * @returns {boolean | undefined} */ export function parseBoolean(value) { if (/^(true|yes|1|on)$/i.test(value)) { return true; } if (/^(false|no|0|off)$/i.test(value)) { return false; } } /** * @param {string} string * @returns {"darwin" | "linux" | "windows" | "freebsd"} */ export function parseOs(string) { if (/darwin|apple|mac/i.test(string)) { return "darwin"; } if (/linux|android/i.test(string)) { return "linux"; } if (/freebsd/i.test(string)) { return "freebsd"; } if (/win/i.test(string)) { return "windows"; } throw new Error(`Unsupported operating system: ${string}`); } /** * @returns {"darwin" | "linux" | "windows" | "freebsd"} */ export function getOs() { return parseOs(process.platform); } /** * @param {string} string * @returns {"x64" | "aarch64"} */ export function parseArch(string) { if (/x64|amd64|x86_64/i.test(string)) { return "x64"; } if (/arm64|aarch64/i.test(string)) { return "aarch64"; } throw new Error(`Unsupported architecture: ${string}`); } /** * @returns {"x64" | "aarch64"} */ export function getArch() { return parseArch(process.arch); } /** * @returns {string | undefined} */ export function getKernel() { if (isWindows) { return; } const kernel = release(); const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(kernel); if (match) { const [, major, minor, patch] = match; if (patch) { return `${major}.${minor}.${patch}`; } return `${major}.${minor}`; } return kernel; } /** * @returns {"musl" | "gnu" | "android" | undefined} */ export function getAbi() { if (!isLinux) { return; } if (isAndroid || existsSync("/system/bin/linker64")) { return "android"; } if (existsSync("/etc/alpine-release")) { return "musl"; } const arch = getArch() === "x64" ? "x86_64" : "aarch64"; const muslLibPath = `/lib/ld-musl-${arch}.so.1`; if (existsSync(muslLibPath)) { return "musl"; } const gnuLibPath = `/lib/ld-linux-${arch}.so.2`; if (existsSync(gnuLibPath)) { return "gnu"; } const { error, stdout } = spawnSync(["ldd", "--version"]); if (!error) { if (/musl/i.test(stdout)) { return "musl"; } if (/gnu|glibc/i.test(stdout)) { return "gnu"; } } } /** * @returns {string | undefined} */ export function getAbiVersion() { if (!isLinux) { return; } const { error, stdout } = spawnSync(["ldd", "--version"]); if (!error) { const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(stdout); if (match) { const [, major, minor, patch] = match; if (patch) { return `${major}.${minor}.${patch}`; } return `${major}.${minor}`; } } } /** * @returns {string} */ export function getTailscale() { if (isMacOS) { const tailscaleApp = "/Applications/Tailscale.app/Contents/MacOS/tailscale"; if (existsSync(tailscaleApp)) { return tailscaleApp; } } if (isWindows) { const tailscaleExe = "C:\\Program Files\\Tailscale\\tailscale.exe"; if (existsSync(tailscaleExe)) { return tailscaleExe; } } return "tailscale"; } /** * @returns {string | undefined} */ export function getTailscaleIp() { const tailscale = getTailscale(); const { error, stdout } = spawnSync([tailscale, "ip", "--1"]); if (!error) { return stdout.trim(); } } /** * @returns {string | undefined} */ export function getPublicIp() { for (const url of ["https://checkip.amazonaws.com", "https://ipinfo.io/ip"]) { const { error, stdout } = spawnSync(["curl", url]); if (!error) { return stdout.trim(); } } } /** * @returns {string} */ export function getHostname() { if (isBuildkite) { const agent = getEnv("BUILDKITE_AGENT_NAME", false); if (agent) { return agent; } } if (isGithubAction) { const runner = getEnv("RUNNER_NAME", false); if (runner) { return runner; } } return hostname(); } /** * @returns {string} */ export function getUsername() { const { username } = userInfo(); return username; } /** * @param {string} distro * @returns {string} */ export function getUsernameForDistro(distro) { if (/windows/i.test(distro)) { return "administrator"; } if (/alpine|centos/i.test(distro)) { return "root"; } if (/debian/i.test(distro)) { return "admin"; } if (/ubuntu/i.test(distro)) { return "ubuntu"; } if (/amazon|amzn|al\d+|rhel/i.test(distro)) { return "ec2-user"; } throw new Error(`Unsupported distro: ${distro}`); } /** * @returns {string | undefined} */ export function getDistro() { if (isMacOS) { return "macOS"; } if (isLinux) { const alpinePath = "/etc/alpine-release"; if (existsSync(alpinePath)) { return "alpine"; } const releasePath = "/etc/os-release"; if (existsSync(releasePath)) { const releaseFile = readFile(releasePath, { cache: true }); const match = releaseFile.match(/^ID=(.*)/m); if (match) { const [, id] = match; return id.includes('"') ? JSON.parse(id) : id; } } const { error, stdout } = spawnSync(["lsb_release", "-is"]); if (!error) { return stdout.trim().toLowerCase(); } } if (isWindows) { const { error, stdout } = spawnSync(["cmd", "/c", "ver"]); if (!error) { return stdout.trim(); } } } /** * @returns {string | undefined} */ export function getDistroVersion() { if (isMacOS) { const { error, stdout } = spawnSync(["sw_vers", "-productVersion"]); if (!error) { return stdout.trim(); } } if (isLinux) { const alpinePath = "/etc/alpine-release"; if (existsSync(alpinePath)) { const release = readFile(alpinePath, { cache: true }).trim(); if (release.includes("_")) { const [version] = release.split("_"); return `${version}-edge`; } return release; } const releasePath = "/etc/os-release"; if (existsSync(releasePath)) { const releaseFile = readFile(releasePath, { cache: true }); const match = releaseFile.match(/^VERSION_ID=(.*)/m); if (match) { const [, release] = match; return release.includes('"') ? JSON.parse(release) : release; } } const { error, stdout } = spawnSync(["lsb_release", "-rs"]); if (!error) { return stdout.trim(); } } if (isWindows) { const { error, stdout } = spawnSync(["cmd", "/c", "ver"]); if (!error) { return stdout.trim(); } } } /** * @returns {string | undefined} */ export function getShell() { if (isWindows) { const pwsh = which(["pwsh", "powershell"]); if (pwsh) { return pwsh; } } const sh = which(["bash", "sh"]); if (sh) { return sh; } return getEnv("SHELL", false); } /** * @typedef {"aws" | "google" | "azure"} Cloud */ /** @type {Cloud | undefined} */ let detectedCloud; /** * @returns {Promise} */ export async function isAws() { if (typeof detectedCloud === "string") { return detectedCloud === "aws"; } async function checkAws() { if (isLinux) { const kernel = release(); if (kernel.endsWith("-aws")) { return true; } const { error: systemdError, stdout } = await spawn(["systemd-detect-virt"]); if (!systemdError) { if (stdout.includes("amazon")) { return true; } } const dmiPath = "/sys/devices/virtual/dmi/id/board_asset_tag"; if (existsSync(dmiPath)) { const dmiFile = readFileSync(dmiPath, { encoding: "utf-8" }); if (dmiFile.startsWith("i-")) { return true; } } } if (isWindows) { const executionEnv = getEnv("AWS_EXECUTION_ENV", false); if (executionEnv === "EC2") { return true; } const { error: powershellError, stdout } = await spawn([ "powershell", "-Command", "Get-CimInstance -ClassName Win32_ComputerSystem | Select-Object Manufacturer", ]); if (!powershellError) { return stdout.includes("Amazon"); } } } if (await checkAws()) { detectedCloud = "aws"; return true; } } /** * @returns {Promise} */ export async function isGoogleCloud() { if (typeof detectedCloud === "string") { return detectedCloud === "google"; } async function detectGoogleCloud() { if (isLinux) { const vendorPaths = [ "/sys/class/dmi/id/sys_vendor", "/sys/class/dmi/id/bios_vendor", "/sys/class/dmi/id/product_name", ]; for (const vendorPath of vendorPaths) { if (existsSync(vendorPath)) { const vendorFile = readFileSync(vendorPath, { encoding: "utf-8" }); if (vendorFile.includes("Google")) { return true; } } } } } if (await detectGoogleCloud()) { detectedCloud = "google"; return true; } } /** * @returns {Promise} */ export async function isAzure() { if (typeof detectedCloud === "string") { return detectedCloud === "azure"; } async function detectAzure() { // Azure IMDS (Instance Metadata Service) — the official way to detect Azure VMs. // https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service const { error, body } = await curl("http://169.254.169.254/metadata/instance?api-version=2021-02-01", { headers: { "Metadata": "true" }, retries: 1, }); if (!error && body) { try { const metadata = JSON.parse(body); if (metadata?.compute?.azEnvironment) { return true; } } catch {} } } if (await detectAzure()) { detectedCloud = "azure"; return true; } } /** * @returns {Promise} */ export async function getCloud() { if (typeof detectedCloud === "string") { return detectedCloud; } if (await isAws()) { return "aws"; } if (await isGoogleCloud()) { return "google"; } if (await isAzure()) { return "azure"; } } /** * @param {string | Record} name * @param {Cloud} [cloud] * @returns {Promise} */ export async function getCloudMetadata(name, cloud) { cloud ??= await getCloud(); if (!cloud) { return; } if (typeof name === "object") { name = name[cloud]; } let url; let headers; if (cloud === "aws") { url = new URL(name, "http://169.254.169.254/latest/meta-data/"); } else if (cloud === "google") { url = new URL(name, "http://metadata.google.internal/computeMetadata/v1/instance/"); headers = { "Metadata-Flavor": "Google" }; } else if (cloud === "azure") { // Azure IMDS uses a single JSON endpoint; individual fields are extracted by the caller. url = new URL("http://169.254.169.254/metadata/instance?api-version=2021-02-01"); headers = { "Metadata": "true" }; } else { throw new Error(`Unsupported cloud: ${inspect(cloud)}`); } const { error, body } = await curl(url, { headers, retries: 10 }); if (error) { console.warn("Failed to get cloud metadata:", error); return; } return body.trim(); } /** * @param {string} tag * @param {Cloud} [cloud] * @returns {Promise} */ export async function getCloudMetadataTag(tag, cloud) { cloud ??= await getCloud(); if (cloud === "azure") { // Azure IMDS returns all tags in a single JSON response. // Tags are in compute.tagsList as [{name, value}, ...]. const body = await getCloudMetadata("", cloud); if (!body) return; try { const metadata = JSON.parse(body); const tags = metadata?.compute?.tagsList; if (Array.isArray(tags)) { const entry = tags.find(t => t.name === tag); return entry?.value; } } catch {} return; } const metadata = { "aws": `tags/instance/${tag}`, "google": `labels/${tag.replace(":", "-")}`, }; return getCloudMetadata(metadata, cloud); } /** * @typedef {Object} AwsCredentials * @property {string} AccessKeyId * @property {string} SecretAccessKey * @property {string} [Token] */ /** * Instance-role credentials from IMDS. * @returns {Promise} */ async function getAwsInstanceCredentials() { const role = await getCloudMetadata("iam/security-credentials/", "aws"); if (!role) { return; } const body = await getCloudMetadata(`iam/security-credentials/${role.trim()}`, "aws"); if (!body) { return; } try { return JSON.parse(body); } catch { return; } } /** * Signs an AWS API request (SigV4). agent.mjs ships to the AMI as a single * bundled file, so this avoids pulling in the SDK. * @param {Object} request * @param {string} request.method * @param {string} request.host * @param {string} request.path * @param {string} request.body * @param {string} request.service * @param {string} request.region * @param {Record} request.headers * @param {AwsCredentials} request.credentials * @param {Date} [request.date] * @returns {Record} headers, including Authorization */ export function signAwsRequest({ method, host, path, body, service, region, headers, credentials, date }) { const { AccessKeyId, SecretAccessKey, Token } = credentials; const amzDate = (date ?? new Date()).toISOString().replace(/[:-]|\.\d{3}/g, ""); const day = amzDate.slice(0, 8); const bodyHash = sha256(body); const signed = { ...headers, "host": host, "x-amz-date": amzDate, "x-amz-content-sha256": bodyHash, }; if (Token) { signed["x-amz-security-token"] = Token; } const canonical = Object.entries(signed) .map(([key, value]) => [key.toLowerCase(), `${value}`.trim()]) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); const canonicalHeaders = canonical.map(([key, value]) => `${key}:${value}\n`).join(""); const signedHeaders = canonical.map(([key]) => key).join(";"); const canonicalRequest = [method, path, "", canonicalHeaders, signedHeaders, bodyHash].join("\n"); const scope = `${day}/${region}/${service}/aws4_request`; const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256(canonicalRequest)].join("\n"); const hmac = (key, data) => createHmac("sha256", key).update(data).digest(); const signingKey = hmac(hmac(hmac(hmac(`AWS4${SecretAccessKey}`, day), region), service), "aws4_request"); const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex"); return { ...signed, "Authorization": `AWS4-HMAC-SHA256 Credential=${AccessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, }; } /** * Reads a secret from AWS Secrets Manager using the instance role. * @param {string} secretId * @param {Object} [options] * @param {string} [options.region] defaults to the instance's region * @param {AwsCredentials} [options.credentials] defaults to IMDS credentials * @returns {Promise} */ export async function getAwsSecret(secretId, options = {}) { const region = options["region"] || (await getCloudMetadata("placement/region", "aws")) || "us-east-1"; const credentials = options["credentials"] || (await getAwsInstanceCredentials()); if (!credentials) { console.warn("Failed to get AWS secret: no instance credentials"); return; } const host = `secretsmanager.${region}.amazonaws.com`; const body = JSON.stringify({ SecretId: secretId }); const headers = signAwsRequest({ method: "POST", host, path: "/", body, service: "secretsmanager", region, credentials, headers: { "Content-Type": "application/x-amz-json-1.1", "X-Amz-Target": "secretsmanager.GetSecretValue", }, }); const { error, body: response } = await curl(`https://${host}/`, { method: "POST", headers, body, json: true, retries: 5, }); if (error) { console.warn("Failed to get AWS secret:", error); return; } return response?.["SecretString"]; } /** * Reads a secret from Azure Key Vault using the VM's managed identity. * @param {string} vaultName * @param {string} secretName * @returns {Promise} */ export async function getAzureSecret(vaultName, secretName) { const identityUrl = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net"; const { error: identityError, body: identity } = await curl(identityUrl, { headers: { "Metadata": "true" }, json: true, retries: 10, }); const accessToken = identity?.["access_token"]; if (identityError || !accessToken) { console.warn("Failed to get Azure managed identity token:", identityError); return; } const secretUrl = `https://${vaultName}.vault.azure.net/secrets/${secretName}?api-version=7.4`; const { error, body } = await curl(secretUrl, { headers: { "Authorization": `Bearer ${accessToken}` }, json: true, retries: 5, }); if (error) { console.warn("Failed to get Azure secret:", error); return; } return body?.["value"]; } /** * @param {string} name * @returns {Promise} */ export async function getBuildMetadata(name) { if (isBuildkite) { const { error, stdout } = await spawn(["buildkite-agent", "meta-data", "get", name]); if (!error) { const value = stdout.trim(); if (value) { return value; } } } } /** * @param {string} name * @param {string} value * @returns {Promise} */ export async function setBuildMetadata(name, value) { if (isBuildkite) { const { error } = await spawn(["buildkite-agent", "meta-data", "set", name, value]); if (error) { console.error(`Failed to set build meta-data '${name}':`, error); } } } /** * @typedef ConnectOptions * @property {string} hostname * @property {number} port * @property {number} [retries] */ /** * @param {ConnectOptions} options * @returns {Promise} */ export async function waitForPort(options) { const { hostname, port, retries = 10 } = options; console.log("Connecting...", `${hostname}:${port}`); let cause; for (let i = 0; i < retries; i++) { if (cause) { await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000)); } const connected = new Promise((resolve, reject) => { const socket = connect({ host: hostname, port }); socket.on("connect", () => { socket.destroy(); console.log("Connected:", `${hostname}:${port}`); resolve(); }); socket.on("error", error => { socket.destroy(); reject(error); }); }); try { return await connected; } catch (error) { cause = error; } } console.error("Connection failed:", `${hostname}:${port}`); return cause; } /** * @returns {Promise} */ export async function getCanaryRevision() { if (isPullRequest() || isFork()) { return 1; } const repository = getRepository() || "oven-sh/bun"; const { error: releaseError, body: release } = await curl( new URL(`repos/${repository}/releases/latest`, getGithubApiUrl()), { json: true }, ); if (releaseError) { return 1; } const commit = getCommit(); const { tag_name: latest } = release; const { error: compareError, body: compare } = await curl( new URL(`repos/${repository}/compare/${latest}...${commit}`, getGithubApiUrl()), { json: true }, ); if (compareError) { return 1; } const { ahead_by: revision } = compare; if (typeof revision === "number") { return revision; } return 1; } /** * @returns {URL} */ export function getGithubApiUrl() { return new URL(getEnv("GITHUB_API_URL", false) || "https://api.github.com"); } /** * @returns {URL} */ export function getGithubUrl() { return new URL(getEnv("GITHUB_SERVER_URL", false) || "https://github.com"); } /** * @param {string} string * @returns {string} */ export function sha256(string) { return createHash("sha256").update(Buffer.from(string)).digest("hex"); } /** * @param {string} [level] * @returns {"info" | "warning" | "error"} */ function parseLevel(level) { if (/error|fatal|fail/i.test(level)) { return "error"; } if (/warn|caution/i.test(level)) { return "warning"; } return "notice"; } /** * @typedef {Object} Annotation * @property {string} title * @property {string} [content] * @property {string} [source] * @property {"notice" | "warning" | "error"} [level] * @property {string} [url] * @property {string} [filename] * @property {number} [line] * @property {number} [column] * @property {Record} [metadata] */ /** * @typedef {Object} AnnotationContext * @property {string} [cwd] * @property {string[]} [command] */ /** * @param {Partial>} options * @param {AnnotationContext} [context] * @returns {Annotation} */ export function parseAnnotation(options, context) { const cwd = (context?.["cwd"] || process.cwd()).replace(/\\/g, "/"); const source = options["source"]; const level = parseLevel(options["level"]); const title = options["title"] || (source ? `${source} ${level}` : level); const path = options["filename"]?.replace(/\\/g, "/"); const line = parseInt(options["line"]) || undefined; const column = parseInt(options["column"]) || undefined; const content = options["content"]; const lines = Array.isArray(content) ? content : content?.split(/\r?\n/) || []; const metadata = Object.fromEntries( Object.entries(options["metadata"] || {}).filter(([, value]) => value !== undefined), ); // Drop leading blank lines, collapse runs of blank lines, and drop the // trailing blank line(s) a readUntil() in parseAnnotations() may have // consumed as a block terminator. const relevantLines = []; let lastLine; for (const line of lines) { if (!lastLine && !line.trim()) { continue; } lastLine = line.trim(); relevantLines.push(line); } while (relevantLines.length > 0 && !relevantLines[relevantLines.length - 1].trim()) { relevantLines.pop(); } let filename; if (path?.startsWith(cwd)) { filename = path.slice(cwd.length + 1); } else { filename = path; } return { source, title, level, filename, line, column, content: relevantLines.join("\n"), metadata, }; } /** * @typedef {Object} AnnotationFormatOptions * @property {boolean} [concise] * @property {boolean} [buildkite] */ /** * @param {Annotation} annotation * @param {AnnotationFormatOptions} [options] * @returns {string} */ export function formatAnnotationToHtml(annotation, options = {}) { const { title, content, source, level, filename, line } = annotation; const { concise, buildkite = isBuildkite } = options; let html; if (concise) { html = "
  • "; } else { html = "
    "; } if (filename) { const filePath = filename.replace(/\\/g, "/"); const fileUrl = getFileUrl(filePath, line); if (fileUrl) { html += `${filePath}`; } else { html += `${filePath}`; } html += " - "; } if (title) { html += title; } else if (source) { if (level) { html += `${source} ${level}`; } else { html += source; } } else if (level) { html += level; } else { html += "unknown error"; } const buildLabel = getBuildLabel(); if (buildLabel) { html += " on "; const buildUrl = getBuildUrl(); if (buildUrl) { html += `${buildLabel}`; } else { html += buildLabel; } } if (concise) { html += "
  • \n"; } else { html += "\n\n"; if (buildkite) { const preview = escapeCodeBlock(content); html += `\`\`\`terminal\n${preview}\n\`\`\`\n`; } else { const preview = escapeHtml(stripAnsi(content)); html += `
    ${preview}
    \n`; } html += "\n\n\n\n"; } return html; } /** * @typedef {Object} AnnotationResult * @property {Annotation[]} annotations * @property {string} content * @property {string} preview */ /** * @param {string} content * @param {AnnotationOptions} [options] * @returns {AnnotationResult} */ export function parseAnnotations(content) { /** @type {Annotation[]} */ const annotations = []; const originalLines = content.split(/\r?\n/); const lines = []; for (let i = 0; i < originalLines.length; i++) { const originalLine = originalLines[i]; const line = stripAnsi(originalLine).trim(); const bufferedLines = [originalLine]; /** * Consume the lines after the current one into `bufferedLines`, through * the first line matching `pattern` (inclusive) or `maxLines` lines if * none matches. Leaves `i` on the last consumed line, so the outer loop * resumes after it; can be called again to consume further. * * @param {RegExp} pattern * @param {number} [maxLines] * @returns {{lines: string[], match: RegExpExecArray | undefined}} */ const readUntil = (pattern, maxLines = 100) => { const start = i + 1; let match; while (i + 1 < originalLines.length && i + 1 - start < maxLines) { i++; const patternMatch = pattern.exec(stripAnsi(originalLines[i]).trim()); if (patternMatch) { match = patternMatch; break; } } const lines = originalLines.slice(start, i + 1); bufferedLines.push(...lines); return { lines, match }; }; // Github Actions // https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions const githubAnnotation = line.match(/^::(error|warning|notice|debug)(?: (.*))?::(.*)$/); if (githubAnnotation) { const [, level, attributes, content] = githubAnnotation; const { file, line, col, title } = Object.fromEntries( attributes?.split(",")?.map(entry => entry.split("=")) || {}, ); const annotation = parseAnnotation({ level, filename: file, line, column: col, content: unescapeGitHubAction(title) + unescapeGitHubAction(content), }); annotations.push(annotation); continue; } const githubCommand = line.match(/^::(group|endgroup|add-mask|stop-commands)::$/); if (githubCommand) { continue; } // CMake error format // e.g. CMake Error at /path/to/thing.cmake:123 (message): ... const cmakeMessage = line.match(/CMake (Error|Warning|Deprecation Warning) at (.*):(\d+)/i); if (cmakeMessage) { let [, level, filename, line] = cmakeMessage; const { match: callStackMatch } = readUntil(/Call Stack \(most recent call first\)/i); if (callStackMatch) { const { match: callFrameMatch } = readUntil(/(CMakeLists\.txt|[^\s]+\.cmake):(\d+)/i, 5); if (callFrameMatch) { const [, frame, location] = callFrameMatch; filename = frame; line = location; } } const annotation = parseAnnotation({ source: "cmake", level, filename, line, content: bufferedLines, }); annotations.push(annotation); } // rustc / cargo error // e.g. error[E0308]: mismatched types // --> src/http/lib.rs:553:5 // The header line carries the level + (optional) code; the location // arrives on the following `-->` line (absent for diagnostics without a // span, e.g. "error: linking with `cc` failed"). The body runs until the // blank line rustc emits after every diagnostic, so the annotation // contains the rendered span + help/note lines; the cap is only a guard // against output that never has one. const rustHeader = line.match(/^(error|warning)(\[[A-Z0-9]+\])?: (.+)$/); if (rustHeader && !/\b(generated|emitted)\b/.test(line) /* "warning: 3 warnings emitted" */) { const [, level, code, title] = rustHeader; const { lines: body } = readUntil(/^$/, 30); const locMatch = stripAnsi(body[0] ?? "").match(/-->\s+(.+?):(\d+):(\d+)/); const annotation = parseAnnotation({ source: "rustc", level, filename: locMatch?.[1], line: locMatch?.[2], column: locMatch?.[3], title: code ? `${code} ${title}` : title, content: bufferedLines, }); annotations.push(annotation); continue; } const nodeJsError = line.match(/^file:\/\/(.+\.(?:c|m)js):(\d+)/i); if (nodeJsError) { const [, filename, line] = nodeJsError; let metadata; const { match: nodeJsVersionMatch } = readUntil(/^Node\.js v(\d+\.\d+\.\d+)/i); if (nodeJsVersionMatch) { const [, version] = nodeJsVersionMatch; metadata = { "node-version": version, }; } const annotation = parseAnnotation({ source: "node", level: "error", filename, line, content: bufferedLines, metadata, }); annotations.push(annotation); } const clangError = line.match(/^(.+\.(?:cpp|c|m|h)):(\d+):(\d+): (error|warning): (.+)/i); if (clangError) { const [, filename, line, column, level] = clangError; readUntil(/^\d+ (?:error|warning)s? generated/); const annotation = parseAnnotation({ source: "clang", level, filename, line, column, content: bufferedLines, }); annotations.push(annotation); } const shellMessage = line.match(/(.+\.sh): line (\d+): (.+)/i); if (shellMessage) { const [, filename, line] = shellMessage; const annotation = parseAnnotation({ source: "shell", level: "error", filename, line, content: bufferedLines, }); annotations.push(annotation); } lines.push(originalLine); } return { annotations, content: lines.join("\n"), }; } /** * @typedef {object} BuildkiteAnnotation * @property {string} [context] * @property {string} label * @property {string} content * @property {"error" | "warning" | "info"} [style] * @property {number} [priority] * @property {number} [attempt] */ /** * @param {BuildkiteAnnotation} annotation */ export function reportAnnotationToBuildKite({ context, label, content, style = "error", priority = 3, attempt = 0 }) { if (!isBuildkite) { return; } // BuildKite rejects annotation contexts > 100 chars (`400 Bad Request: This // context is too long`). rustc diagnostic titles routinely exceed that and // were silently dropped, leaving only short warnings visible in the UI. const ctx = `${context || label}`.slice(0, 100); const { error, status, signal, stderr } = nodeSpawnSync( "buildkite-agent", ["annotate", "--append", "--style", `${style}`, "--context", ctx, "--priority", `${priority}`], { input: content, stdio: ["pipe", "ignore", "pipe"], encoding: "utf-8", timeout: 30_000, }, ); if (status === 0) { return; } const cause = error?.message || signal || (status == null ? "timed out" : `exit code ${status}`); if (attempt === 0) { console.error(`buildkite-agent annotate failed for '${label}' (${cause}), retrying...`); return reportAnnotationToBuildKite({ context, label, content, style, priority, attempt: attempt + 1 }); } // Annotations are best-effort: log and move on rather than throwing, which // would abort the test runner mid-suite over a cosmetic failure. console.error(`buildkite-agent annotate failed for '${label}' after retry (${cause}), giving up`); if (stderr) console.error(stderr); } /** * Mark this Buildkite job as having handled its own failure reporting. * * The repository `.buildkite/hooks/pre-exit` hook posts a generic fallback * annotation for any step that exits non-zero without this marker set, so * infra failures that happen before (or crash) the runner/build scripts are * still surfaced in the build's annotation list instead of being visible only * in the raw job log. Call this from every controlled exit path that has * already posted (or had nothing to post) so the fallback stays quiet. The * marker is build meta-data, which is server-side and so remains visible to * the host pre-exit hook even when the reporter ran inside an ephemeral VM. */ export function markBuildkiteStepReported() { if (!isBuildkite) return; const jobId = getEnv("BUILDKITE_JOB_ID", false); if (!jobId) return; const { status } = nodeSpawnSync("buildkite-agent", ["meta-data", "set", `reported-${jobId}`, "1"], { stdio: "ignore", timeout: 30_000, }); if (status !== 0) { console.error(`buildkite-agent meta-data set reported-${jobId} failed (non-fatal)`); } } /** * @param {object} obj * @param {number} indent * @returns {string} */ export function toYaml(obj, indent = 0) { const spaces = " ".repeat(indent); let result = ""; for (const [key, value] of Object.entries(obj)) { if (value === undefined) { continue; } if (value === null) { result += `${spaces}${key}: null\n`; continue; } if (Array.isArray(value)) { result += `${spaces}${key}:\n`; value.forEach(item => { if (typeof item === "object" && item !== null) { result += `${spaces}- \n${toYaml(item, indent + 2) .split("\n") .map(line => `${spaces} ${line}`) .join("\n")}\n`; } else { result += `${spaces}- ${item}\n`; } }); continue; } if (typeof value === "object") { result += `${spaces}${key}:\n${toYaml(value, indent + 2)}`; continue; } if ( typeof value === "string" && (value.includes(":") || value.includes("#") || value.includes("'") || value.includes('"') || value.includes("\\") || value.includes("\n") || value.includes("*") || value.includes("&") || value.includes("!") || value.includes("|") || value.includes(">") || value.includes("%") || value.includes("@") || value.includes("`") || value.includes("{") || value.includes("}") || value.includes("[") || value.includes("]") || value.includes(",") || value.includes(";")) ) { result += `${spaces}${key}: "${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"\n`; continue; } result += `${spaces}${key}: ${value}\n`; } return result; } /** @type {string | undefined} */ let lastGroup; /** * @param {string} title * @param {function} [fn] */ export function startGroup(title, fn) { if (lastGroup && lastGroup !== title) { lastGroup = title; endGroup(); } if (isGithubAction) { console.log(`::group::${stripAnsi(title)}`); } else if (isBuildkite) { console.log(`--- ${title}`); } else { console.group(title); } if (typeof fn === "function") { let result; try { result = fn(); } finally { if (result instanceof Promise) { return result.finally(() => endGroup()); } else { endGroup(); } } } } export function endGroup() { if (lastGroup) { lastGroup = undefined; } if (isGithubAction) { console.log("::endgroup::"); } else { console.groupEnd(); } // when a file exits with an ASAN error, there is no trailing newline so we add one here to make sure `console.group()` detection doesn't get broken in CI. console.log(); } export function printEnvironment() { startGroup("Machine", () => { console.log("Operating System:", getOs()); console.log("Architecture:", getArch()); console.log("Kernel:", getKernel()); if (isLinux) { console.log("ABI:", getAbi()); console.log("ABI Version:", getAbiVersion()); } console.log("Distro:", getDistro()); console.log("Distro Version:", getDistroVersion()); console.log("Hostname:", getHostname()); if (isCI) { console.log("Tailscale IP:", getTailscaleIp()); console.log("Public IP:", getPublicIp()); } console.log("Username:", getUsername()); console.log("Working Directory:", process.cwd()); console.log("Temporary Directory:", tmpdir()); if (process.isBun) { console.log("Bun Version:", Bun.version, Bun.revision); } else { console.log("Node Version:", process.version); } }); if (isCI) { startGroup("Environment", () => { for (const [key, value] of Object.entries(process.env).toSorted()) { console.log(`${key}:`, value); } }); if (isPosix) { startGroup("Limits", () => { const shell = which(["sh", "bash"]); if (shell) { spawnSync([shell, "-c", "ulimit -a"], { stdio: "inherit" }); } }); startGroup("Disk (df)", () => { const shell = which(["sh", "bash"]); if (shell) { spawnSync([shell, "-c", "df"], { stdio: "inherit" }); } }); } if (isLinux) { startGroup("Memory", () => { const shell = which(["sh", "bash"]); if (shell) { spawnSync([shell, "-c", "free -m -w"], { stdio: "inherit" }); } }); startGroup("Docker", () => { const shell = which(["sh", "bash"]); if (shell) { spawnSync([shell, "-c", "docker ps"], { stdio: "inherit" }); } }); } if (isWindows) { startGroup("Disk (win)", () => { const shell = which(["pwsh"]); if (shell) { spawnSync([shell, "-c", "get-psdrive"], { stdio: "inherit" }); } }); startGroup("Memory", () => { const shell = which(["pwsh"]); if (shell) { spawnSync([shell, "-c", "Get-Counter '\\Memory\\Available MBytes'"], { stdio: "inherit" }); console.log(); spawnSync([shell, "-c", "Get-CimInstance Win32_PhysicalMemory"], { stdio: "inherit" }); } }); } } startGroup("Repository", () => { console.log("Commit:", getCommit()); console.log("Message:", getCommitMessage()); console.log("Branch:", getBranch()); console.log("Main Branch:", getMainBranch()); console.log("Is Fork:", isFork()); console.log("Is Merge Queue:", isMergeQueue()); console.log("Is Main Branch:", isMainBranch()); console.log("Is Pull Request:", isPullRequest()); if (isPullRequest()) { console.log("Pull Request:", getPullRequest()); console.log("Target Branch:", getTargetBranch()); } }); if (isCI) { startGroup("CI", () => { console.log("Build ID:", getBuildId()); console.log("Build Label:", getBuildLabel()); console.log("Build URL:", getBuildUrl()?.toString()); }); } } /** * @returns {number | undefined} */ export function getLoggedInUserCountOrDetails() { if (isWindows) { const pwsh = which(["pwsh", "powershell"]); if (pwsh) { const { error, stdout } = spawnSync([ pwsh, "-Command", `Get-CimInstance -ClassName Win32_Process -Filter "Name = 'sshd.exe'" | Get-CimAssociatedInstance -Association Win32_SessionProcess | Get-CimAssociatedInstance -Association Win32_LoggedOnUser | Where-Object {$_.Name -ne 'SYSTEM'} | Measure-Object | Select-Object -ExpandProperty Count`, ]); if (!error) { return parseInt(stdout) || undefined; } } } const { error, stdout } = spawnSync(["who"]); if (!error) { const users = stdout .split("\n") .filter(line => /tty|pts/i.test(line)) // Only count REMOTE logins (have an `(ip)` suffix from sshd). A local // console/auto-login (e.g. cirruslabs CI VM images log the admin user in // on ttys000 at boot) has no source host and isn't a human debugging the // job — waiting for it would hang the runner forever. .filter(line => /\([^)]+\)\s*$/.test(line)) .map(line => { // `who` output: `username terminal date time (host)`. The date/time // field has spaces, so a plain split() can't slice it cleanly — take // the first two tokens and pull the host from the trailing `(...)`. const [username, terminal] = line.split(/\s+/); const ip = line.match(/\(([^)]+)\)\s*$/)?.[1] || ""; return { username, terminal, ip }; }); if (users.length === 0) { return 0; } let message = `${users.length} currently logged in users:`; for (const user of users) { message += `\n- ${user.username} on ${user.terminal}${user.ip ? ` from ${user.ip}` : ""}`; } return message; } } /** @typedef {keyof typeof emojiMap} Emoji */ const emojiMap = { darwin: ["🍎", "darwin"], linux: ["🐧", "linux"], debian: ["🐧", "debian"], ubuntu: ["🐧", "ubuntu"], alpine: ["🐧", "alpine"], aws: ["☁️", "aws"], amazonlinux: ["🐧", "aws"], nix: ["🐧", "nix"], windows: ["🪟", "windows"], true: ["✅", "white_check_mark"], false: ["❌", "x"], debug: ["🐞", "bug"], asan: ["🐛", "bug"], assert: ["🔍", "mag"], release: ["🏆", "trophy"], gear: ["⚙️", "gear"], clipboard: ["📋", "clipboard"], package: ["📦", "package"], rocket: ["🚀", "rocket"], openbsd: ["🐡", "openbsd"], netbsd: ["🚩", "netbsd"], freebsd: ["😈", "freebsd"], }; /** * @param {Emoji} emoji * @returns {string} */ export function getEmoji(emoji) { const [unicode] = emojiMap[emoji] || []; return unicode || ""; } /** * @param {Emoji} emoji * @returns {string} * @link https://github.com/buildkite/emojis#emoji-reference */ export function getBuildkiteEmoji(emoji) { const [, name] = emojiMap[emoji] || []; return name ? `:${name}:` : ""; } /** * @param {SshOptions} options * @param {import("./utils.mjs").SpawnOptions} [spawnOptions] * @returns {Promise} */ export async function spawnSshSafe(options, spawnOptions = {}) { return spawnSsh(options, { throwOnError: true, ...spawnOptions }); } /** * @param {SshOptions} options * @param {import("./utils.mjs").SpawnOptions} [spawnOptions] * @returns {Promise} */ export async function spawnSsh(options, spawnOptions = {}) { const { hostname, port, username, identityPaths, password, retries = 10, command: spawnCommand } = options; if (!hostname.includes("@")) { await waitForPort({ hostname, port: port || 22, }); } const logPath = mkdtemp("ssh-", "ssh.log"); const command = ["ssh", hostname, "-v", "-C", "-E", logPath, "-o", "StrictHostKeyChecking=no"]; if (!password) { command.push("-o", "BatchMode=yes"); } if (port) { command.push("-p", port); } if (username) { command.push("-l", username); } if (password) { const sshPass = which("sshpass", { required: true }); command.unshift(sshPass, "-p", password); } else if (identityPaths) { command.push(...identityPaths.flatMap(path => ["-i", path])); } const stdio = spawnCommand ? "pipe" : "inherit"; if (spawnCommand) { command.push(...spawnCommand); } /** @type {import("./utils.mjs").SpawnResult} */ let result; for (let i = 0; i < retries; i++) { result = await spawn(command, { stdio, ...spawnOptions, throwOnError: undefined }); const { exitCode } = result; if (exitCode !== 255) { break; } const sshLogs = readFile(logPath, { encoding: "utf-8" }); if (sshLogs.includes("Authenticated")) { break; } await new Promise(resolve => setTimeout(resolve, (i + 1) * 15000)); } if (spawnOptions?.throwOnError) { const { error } = result; if (error) { throw error; } } return result; } /** * @param {MachineOptions} options * @returns {Promise} */ export async function setupUserData(machine, options) { const { os, userData } = options; if (!userData) { return; } // Write user data to a temporary file const tmpFile = mkdtemp("user-data-", os === "windows" ? "setup.ps1" : "setup.sh"); await writeFile(tmpFile, userData); try { // Upload the script const remotePath = os === "windows" ? "C:\\Windows\\Temp\\setup.ps1" : "/tmp/setup.sh"; await machine.upload(tmpFile, remotePath); // Execute the script if (os === "windows") { await machine.spawnSafe(["powershell", remotePath], { stdio: "inherit" }); } else { await machine.spawnSafe(["bash", remotePath], { stdio: "inherit" }); } } finally { // Clean up the temporary file rm(tmpFile); } }