Files
openclaw/scripts/ci-runner-labels.mjs
2026-05-07 01:45:20 +01:00

201 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
import { appendFileSync } from "node:fs";
export const RUNNER_LABELS = {
runner_4vcpu_ubuntu: {
fallback: "ubuntu-24.04",
family: "ubuntu-2404",
primary: "blacksmith-4vcpu-ubuntu-2404",
},
runner_8vcpu_ubuntu: {
fallback: "ubuntu-24.04",
family: "ubuntu-2404",
primary: "blacksmith-8vcpu-ubuntu-2404",
},
runner_16vcpu_ubuntu: {
fallback: "ubuntu-24.04",
family: "ubuntu-2404",
primary: "blacksmith-16vcpu-ubuntu-2404",
},
runner_16vcpu_windows: {
fallback: "windows-2025",
family: "windows-2025",
primary: "blacksmith-16vcpu-windows-2025",
},
runner_6vcpu_macos: {
fallback: "macos-latest",
family: "macos-latest",
primary: "blacksmith-6vcpu-macos-latest",
},
runner_12vcpu_macos: {
fallback: "macos-latest",
family: "macos-latest",
primary: "blacksmith-12vcpu-macos-latest",
},
};
const DEFAULT_REPOSITORY = "openclaw/openclaw";
const DEFAULT_QUEUE_THRESHOLD = 1;
const MAX_RUNS_TO_SCAN = 8;
const MAX_JOB_PAGES_PER_RUN = 2;
function parseBoolean(value, fallback = false) {
if (value === undefined) {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (normalized === "1" || normalized === "true" || normalized === "yes") {
return true;
}
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "") {
return false;
}
return fallback;
}
function parsePositiveInteger(value, fallback) {
const parsed = Number.parseInt(value ?? "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function selectRunnerLabels({
canonicalRepository = true,
fallbackEnabled = true,
queuedCountsByLabel = {},
queueThreshold = DEFAULT_QUEUE_THRESHOLD,
} = {}) {
const selected = {};
for (const [outputName, label] of Object.entries(RUNNER_LABELS)) {
const queuedCount = queuedCountsByLabel[label.primary] ?? 0;
selected[outputName] =
!canonicalRepository || (fallbackEnabled && queuedCount >= queueThreshold)
? label.fallback
: label.primary;
}
return selected;
}
async function githubApi(path, token) {
const response = await fetch(`https://api.github.com/${path}`, {
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"x-github-api-version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`GitHub API ${path} failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
async function collectQueuedBlacksmithJobs({ repository, token }) {
const [queuedRuns, inProgressRuns] = await Promise.all([
githubApi(
`repos/${repository}/actions/runs?status=queued&per_page=${MAX_RUNS_TO_SCAN}&exclude_pull_requests=true`,
token,
),
githubApi(
`repos/${repository}/actions/runs?status=in_progress&per_page=${MAX_RUNS_TO_SCAN}&exclude_pull_requests=true`,
token,
),
]);
const runsById = new Map();
for (const run of [
...(queuedRuns.workflow_runs ?? []),
...(inProgressRuns.workflow_runs ?? []),
]) {
runsById.set(run.id, run);
}
const counts = {};
await Promise.all(
[...runsById.values()].map(async (run) => {
const runCounts = {};
for (let page = 1; page <= MAX_JOB_PAGES_PER_RUN; page += 1) {
const jobs = await githubApi(
`repos/${repository}/actions/runs/${run.id}/jobs?per_page=100&page=${page}`,
token,
);
for (const job of jobs.jobs ?? []) {
if (job.status !== "queued") {
continue;
}
for (const label of job.labels ?? []) {
if (typeof label === "string" && label.startsWith("blacksmith-")) {
runCounts[label] = (runCounts[label] ?? 0) + 1;
}
}
}
if ((jobs.jobs ?? []).length < 100) {
break;
}
}
for (const [label, count] of Object.entries(runCounts)) {
counts[label] = (counts[label] ?? 0) + count;
}
}),
);
return counts;
}
function writeOutputs(outputs) {
const outputPath = process.env.GITHUB_OUTPUT;
if (!outputPath) {
console.log(JSON.stringify(outputs, null, 2));
return;
}
for (const [key, value] of Object.entries(outputs)) {
appendFileSync(outputPath, `${key}=${String(value)}\n`, "utf8");
}
}
async function main() {
const repository = process.env.GITHUB_REPOSITORY || DEFAULT_REPOSITORY;
const canonicalRepository = repository === DEFAULT_REPOSITORY;
const fallbackEnabled = parseBoolean(process.env.OPENCLAW_CI_BLACKSMITH_FALLBACK, true);
const queueThreshold = parsePositiveInteger(
process.env.OPENCLAW_CI_BLACKSMITH_QUEUE_FALLBACK_THRESHOLD,
DEFAULT_QUEUE_THRESHOLD,
);
let queuedCountsByLabel = {};
if (canonicalRepository && fallbackEnabled && process.env.GITHUB_TOKEN) {
try {
queuedCountsByLabel = await collectQueuedBlacksmithJobs({
repository,
token: process.env.GITHUB_TOKEN,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`::warning title=Blacksmith fallback probe failed::${message}`);
}
}
const selected = selectRunnerLabels({
canonicalRepository,
fallbackEnabled,
queuedCountsByLabel,
queueThreshold,
});
console.log(
JSON.stringify(
{
fallbackEnabled,
queueThreshold,
queuedCountsByLabel,
selected,
},
null,
2,
),
);
writeOutputs(selected);
}
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}