mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
ci: add runner fallback timing telemetry
This commit is contained in:
90
.github/workflows/ci.yml
vendored
90
.github/workflows/ci.yml
vendored
@@ -36,6 +36,7 @@ jobs:
|
||||
# work fan out from a single source of truth.
|
||||
preflight:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -75,6 +76,12 @@ jobs:
|
||||
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
|
||||
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
|
||||
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
|
||||
runner_4vcpu_ubuntu: ${{ steps.runner_labels.outputs.runner_4vcpu_ubuntu }}
|
||||
runner_8vcpu_ubuntu: ${{ steps.runner_labels.outputs.runner_8vcpu_ubuntu }}
|
||||
runner_16vcpu_ubuntu: ${{ steps.runner_labels.outputs.runner_16vcpu_ubuntu }}
|
||||
runner_16vcpu_windows: ${{ steps.runner_labels.outputs.runner_16vcpu_windows }}
|
||||
runner_6vcpu_macos: ${{ steps.runner_labels.outputs.runner_6vcpu_macos }}
|
||||
runner_12vcpu_macos: ${{ steps.runner_labels.outputs.runner_12vcpu_macos }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -295,6 +302,13 @@ jobs:
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Select runner labels
|
||||
id: runner_labels
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_CI_BLACKSMITH_FALLBACK: "true"
|
||||
run: node scripts/ci-runner-labels.mjs
|
||||
|
||||
# Run the fast security/SCM checks in parallel with scope detection so the
|
||||
# main Node jobs do not have to wait for Python/pre-commit setup.
|
||||
security-scm-fast:
|
||||
@@ -452,7 +466,7 @@ jobs:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_8vcpu_ubuntu || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
|
||||
@@ -651,7 +665,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_4vcpu_ubuntu || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -740,13 +754,67 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
ci-timings-summary:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
name: ci-timings-summary
|
||||
needs:
|
||||
- preflight
|
||||
- security-fast
|
||||
- build-artifacts
|
||||
- checks-fast-core
|
||||
- checks-fast-plugin-contracts
|
||||
- checks-fast-channel-contracts
|
||||
- checks-fast-protocol
|
||||
- checks
|
||||
- checks-node-compat
|
||||
- checks-node-core-test
|
||||
- check
|
||||
- check-additional
|
||||
- build-smoke
|
||||
- check-docs
|
||||
- skills-python
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Write CI timing summary
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
node scripts/ci-run-timings.mjs "$RUN_ID" --limit 25 > ci-timings-summary.txt
|
||||
cat ci-timings-summary.txt >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload CI timing summary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ci-timings-summary
|
||||
path: ci-timings-summary.txt
|
||||
retention-days: 14
|
||||
|
||||
checks-fast-plugin-contracts-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.checkName }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_4vcpu_ubuntu || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1055,7 +1123,7 @@ jobs:
|
||||
name: checks-node-compat-node22
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_4vcpu_ubuntu || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1132,7 +1200,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository != 'openclaw/openclaw' && 'ubuntu-24.04' || matrix.runner == 'blacksmith-4vcpu-ubuntu-2404' && needs.preflight.outputs.runner_4vcpu_ubuntu || matrix.runner == 'blacksmith-8vcpu-ubuntu-2404' && needs.preflight.outputs.runner_8vcpu_ubuntu || matrix.runner == 'blacksmith-16vcpu-ubuntu-2404' && needs.preflight.outputs.runner_16vcpu_ubuntu || matrix.runner || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1300,7 +1368,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository != 'openclaw/openclaw' && 'ubuntu-24.04' || matrix.runner == 'blacksmith-4vcpu-ubuntu-2404' && needs.preflight.outputs.runner_4vcpu_ubuntu || matrix.runner == 'blacksmith-8vcpu-ubuntu-2404' && needs.preflight.outputs.runner_8vcpu_ubuntu || matrix.runner == 'blacksmith-16vcpu-ubuntu-2404' && needs.preflight.outputs.runner_16vcpu_ubuntu || matrix.runner || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1461,7 +1529,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_8vcpu_ubuntu || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1780,7 +1848,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_windows == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_16vcpu_windows || 'windows-2025' }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
@@ -1893,7 +1961,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_6vcpu_macos || 'macos-latest' }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1937,7 +2005,7 @@ jobs:
|
||||
name: "macos-swift"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_macos_swift == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_12vcpu_macos || 'macos-latest' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -2034,7 +2102,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_android_job == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && needs.preflight.outputs.runner_8vcpu_ubuntu || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
@@ -46,6 +46,8 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
|
||||
|
||||
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Aggregate shard checks use `!cancelled() && always()` so they still report normal shard failures but do not queue after the whole workflow has already been superseded. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
|
||||
|
||||
The `ci-timings-summary` job uploads a compact `ci-timings-summary` artifact for each non-draft CI run. It records wall time, queue time, slowest jobs, and failed jobs for the current run, so CI health checks do not need to scrape the full Actions payload repeatedly.
|
||||
|
||||
## Scope and routing
|
||||
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
|
||||
@@ -101,6 +103,8 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
| `blacksmith-6vcpu-macos-latest` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-latest` |
|
||||
| `blacksmith-12vcpu-macos-latest` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-latest` |
|
||||
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path. During `preflight`, `scripts/ci-runner-labels.mjs` checks recent queued and in-progress Actions runs for queued Blacksmith jobs. If a specific Blacksmith label already has queued jobs, downstream jobs that would use that label fall back to the matching GitHub-hosted runner (`ubuntu-24.04`, `windows-2025`, or `macos-latest`) for that run only. If the API probe fails, no fallback is applied.
|
||||
|
||||
## Local equivalents
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const DEFAULT_REPOSITORY = "openclaw/openclaw";
|
||||
const CI_WORKFLOW_ID = "ci.yml";
|
||||
const GH_MAX_BUFFER = 32 * 1024 * 1024;
|
||||
|
||||
function parseTime(value) {
|
||||
if (!value || value === "0001-01-01T00:00:00Z") {
|
||||
return null;
|
||||
@@ -23,10 +27,36 @@ function parseRunList(raw) {
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function normalizeRun(run) {
|
||||
return {
|
||||
...run,
|
||||
createdAt: run.createdAt ?? run.created_at,
|
||||
databaseId: run.databaseId ?? run.id,
|
||||
displayTitle: run.displayTitle ?? run.display_title,
|
||||
event: run.event,
|
||||
headSha: run.headSha ?? run.head_sha,
|
||||
runStartedAt: run.runStartedAt ?? run.run_started_at,
|
||||
status: run.status,
|
||||
conclusion: run.conclusion,
|
||||
updatedAt: run.updatedAt ?? run.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJob(job) {
|
||||
return {
|
||||
...job,
|
||||
completedAt: job.completedAt ?? job.completed_at,
|
||||
runnerName: job.runnerName ?? job.runner_name,
|
||||
startedAt: job.startedAt ?? job.started_at,
|
||||
};
|
||||
}
|
||||
|
||||
function collectRunTimingContext(run) {
|
||||
const created = parseTime(run.createdAt);
|
||||
const updated = parseTime(run.updatedAt);
|
||||
const jobs = (run.jobs ?? [])
|
||||
const normalizedRun = normalizeRun(run);
|
||||
const created = parseTime(normalizedRun.createdAt);
|
||||
const runUpdated = parseTime(normalizedRun.updatedAt);
|
||||
const jobs = (normalizedRun.jobs ?? [])
|
||||
.map(normalizeJob)
|
||||
.filter((job) => !job.name?.startsWith("matrix."))
|
||||
.map((job) => {
|
||||
const started = parseTime(job.startedAt);
|
||||
@@ -42,11 +72,18 @@ function collectRunTimingContext(run) {
|
||||
};
|
||||
});
|
||||
|
||||
return { created, jobs, updated };
|
||||
const completedTimes = jobs.map((job) => job.completed).filter((completed) => completed !== null);
|
||||
const lastCompleted = completedTimes.length === 0 ? null : Math.max(...completedTimes);
|
||||
const updated =
|
||||
runUpdated !== null && lastCompleted !== null
|
||||
? Math.max(runUpdated, lastCompleted)
|
||||
: (runUpdated ?? lastCompleted);
|
||||
|
||||
return { created, jobs, run: normalizedRun, updated };
|
||||
}
|
||||
|
||||
export function summarizeRunTimings(run, limit = 15) {
|
||||
const { created, jobs, updated } = collectRunTimingContext(run);
|
||||
const { created, jobs, run: normalizedRun, updated } = collectRunTimingContext(run);
|
||||
const byDuration = [...jobs]
|
||||
.filter((job) => job.durationSeconds !== null)
|
||||
.toSorted((left, right) => right.durationSeconds - left.durationSeconds)
|
||||
@@ -62,15 +99,15 @@ export function summarizeRunTimings(run, limit = 15) {
|
||||
return {
|
||||
byDuration,
|
||||
byQueue,
|
||||
conclusion: run.conclusion ?? "",
|
||||
status: run.status ?? "",
|
||||
conclusion: normalizedRun.conclusion ?? "",
|
||||
status: normalizedRun.status ?? "",
|
||||
wallSeconds: secondsBetween(created, updated),
|
||||
badJobs,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectLatestMainPushCiRun(runs, headSha = null) {
|
||||
const pushRuns = runs.filter((run) => run.event === "push");
|
||||
const pushRuns = runs.map(normalizeRun).filter((run) => run.event === "push");
|
||||
if (headSha) {
|
||||
const matchingRun = pushRuns.find((run) => run.headSha === headSha);
|
||||
if (matchingRun) {
|
||||
@@ -80,13 +117,37 @@ export function selectLatestMainPushCiRun(runs, headSha = null) {
|
||||
return pushRuns[0] ?? null;
|
||||
}
|
||||
|
||||
function getLatestCiRunId() {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
["run", "list", "--branch", "main", "--workflow", "CI", "--limit", "1", "--json", "databaseId"],
|
||||
{ encoding: "utf8" },
|
||||
function repositorySlug() {
|
||||
return process.env.GITHUB_REPOSITORY || DEFAULT_REPOSITORY;
|
||||
}
|
||||
|
||||
function ghApiJson(path) {
|
||||
return JSON.parse(
|
||||
execFileSync("gh", ["api", path], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: GH_MAX_BUFFER,
|
||||
}),
|
||||
);
|
||||
const runs = JSON.parse(raw);
|
||||
}
|
||||
|
||||
function listMainCiRuns(limit) {
|
||||
const runs = [];
|
||||
const perPage = Math.max(1, Math.min(100, limit));
|
||||
for (let page = 1; runs.length < limit && page <= 10; page += 1) {
|
||||
const data = ghApiJson(
|
||||
`repos/${repositorySlug()}/actions/workflows/${CI_WORKFLOW_ID}/runs?branch=main&per_page=${perPage}&page=${page}&exclude_pull_requests=true`,
|
||||
);
|
||||
const pageRuns = (data.workflow_runs ?? []).map(normalizeRun);
|
||||
runs.push(...pageRuns);
|
||||
if (pageRuns.length < perPage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return runs.slice(0, limit);
|
||||
}
|
||||
|
||||
function getLatestCiRunId() {
|
||||
const runs = listMainCiRuns(1);
|
||||
const runId = runs[0]?.databaseId;
|
||||
if (!runId) {
|
||||
throw new Error("No CI runs found on main");
|
||||
@@ -105,23 +166,7 @@ function getRemoteMainSha() {
|
||||
|
||||
function getLatestMainPushCiRunId() {
|
||||
const headSha = getRemoteMainSha();
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"run",
|
||||
"list",
|
||||
"--branch",
|
||||
"main",
|
||||
"--workflow",
|
||||
"CI",
|
||||
"--limit",
|
||||
"20",
|
||||
"--json",
|
||||
"databaseId,headSha,event,status,conclusion",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const run = selectLatestMainPushCiRun(parseRunList(raw), headSha);
|
||||
const run = selectLatestMainPushCiRun(listMainCiRuns(40), headSha);
|
||||
if (!run?.databaseId) {
|
||||
throw new Error(`No push CI run found for origin/main ${headSha.slice(0, 10)}`);
|
||||
}
|
||||
@@ -129,37 +174,30 @@ function getLatestMainPushCiRunId() {
|
||||
}
|
||||
|
||||
function listRecentSuccessfulCiRuns(limit) {
|
||||
const raw = execFileSync(
|
||||
"gh",
|
||||
[
|
||||
"run",
|
||||
"list",
|
||||
"--branch",
|
||||
"main",
|
||||
"--workflow",
|
||||
"CI",
|
||||
"--limit",
|
||||
String(Math.max(limit * 4, limit)),
|
||||
"--json",
|
||||
"databaseId,headSha,status,conclusion",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return JSON.parse(raw)
|
||||
return listMainCiRuns(Math.max(limit * 12, 100))
|
||||
.filter((run) => run.status === "completed" && run.conclusion === "success")
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function loadRun(runId) {
|
||||
return JSON.parse(
|
||||
execFileSync(
|
||||
"gh",
|
||||
["run", "view", runId, "--json", "status,conclusion,createdAt,updatedAt,jobs"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
),
|
||||
);
|
||||
const repository = repositorySlug();
|
||||
const run = normalizeRun(ghApiJson(`repos/${repository}/actions/runs/${runId}`));
|
||||
const jobs = [];
|
||||
for (let page = 1; page <= 10; page += 1) {
|
||||
const data = ghApiJson(
|
||||
`repos/${repository}/actions/runs/${runId}/jobs?per_page=100&page=${page}`,
|
||||
);
|
||||
const pageJobs = data.jobs ?? [];
|
||||
jobs.push(...pageJobs.map(normalizeJob));
|
||||
if (pageJobs.length < 100) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...run,
|
||||
createdAt: run.createdAt ?? run.runStartedAt,
|
||||
jobs,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeJobs(run) {
|
||||
@@ -245,7 +283,12 @@ async function main() {
|
||||
process.argv.slice(2),
|
||||
);
|
||||
if (recentLimit !== null) {
|
||||
for (const run of listRecentSuccessfulCiRuns(recentLimit)) {
|
||||
const runs = listRecentSuccessfulCiRuns(recentLimit);
|
||||
if (runs.length === 0) {
|
||||
console.log("No recent successful main CI runs found in the latest 100 runs.");
|
||||
return;
|
||||
}
|
||||
for (const run of runs) {
|
||||
const summary = summarizeJobs(loadRun(run.databaseId));
|
||||
console.log(
|
||||
[
|
||||
|
||||
187
scripts/ci-runner-labels.mjs
Normal file
187
scripts/ci-runner-labels.mjs
Normal file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
export const RUNNER_LABELS = {
|
||||
runner_4vcpu_ubuntu: {
|
||||
fallback: "ubuntu-24.04",
|
||||
primary: "blacksmith-4vcpu-ubuntu-2404",
|
||||
},
|
||||
runner_8vcpu_ubuntu: {
|
||||
fallback: "ubuntu-24.04",
|
||||
primary: "blacksmith-8vcpu-ubuntu-2404",
|
||||
},
|
||||
runner_16vcpu_ubuntu: {
|
||||
fallback: "ubuntu-24.04",
|
||||
primary: "blacksmith-16vcpu-ubuntu-2404",
|
||||
},
|
||||
runner_16vcpu_windows: {
|
||||
fallback: "windows-2025",
|
||||
primary: "blacksmith-16vcpu-windows-2025",
|
||||
},
|
||||
runner_6vcpu_macos: {
|
||||
fallback: "macos-latest",
|
||||
primary: "blacksmith-6vcpu-macos-latest",
|
||||
},
|
||||
runner_12vcpu_macos: {
|
||||
fallback: "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}=${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) {
|
||||
console.log(`::warning title=Blacksmith fallback probe failed::${error.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();
|
||||
}
|
||||
@@ -347,6 +347,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/run-oxlint.mjs", ["test/scripts/run-oxlint.test.ts"]],
|
||||
["scripts/run-node.mjs", ["src/infra/run-node.test.ts"]],
|
||||
["scripts/ci-run-timings.mjs", ["test/scripts/ci-run-timings.test.ts"]],
|
||||
["scripts/ci-runner-labels.mjs", ["test/scripts/ci-runner-labels.test.ts"]],
|
||||
["scripts/test-extension-batch.mjs", ["test/scripts/test-extension.test.ts"]],
|
||||
["scripts/lib/extension-test-plan.mjs", ["test/scripts/test-extension.test.ts"]],
|
||||
["scripts/lib/vitest-batch-runner.mjs", ["test/scripts/test-extension.test.ts"]],
|
||||
|
||||
40
test/scripts/ci-runner-labels.test.ts
Normal file
40
test/scripts/ci-runner-labels.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectRunnerLabels } from "../../scripts/ci-runner-labels.mjs";
|
||||
|
||||
describe("scripts/ci-runner-labels.mjs", () => {
|
||||
it("keeps Blacksmith labels by default", () => {
|
||||
expect(selectRunnerLabels()).toMatchObject({
|
||||
runner_4vcpu_ubuntu: "blacksmith-4vcpu-ubuntu-2404",
|
||||
runner_8vcpu_ubuntu: "blacksmith-8vcpu-ubuntu-2404",
|
||||
runner_16vcpu_ubuntu: "blacksmith-16vcpu-ubuntu-2404",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back only for backed-up Blacksmith labels", () => {
|
||||
expect(
|
||||
selectRunnerLabels({
|
||||
queuedCountsByLabel: {
|
||||
"blacksmith-4vcpu-ubuntu-2404": 3,
|
||||
"blacksmith-8vcpu-ubuntu-2404": 0,
|
||||
},
|
||||
queueThreshold: 2,
|
||||
}),
|
||||
).toMatchObject({
|
||||
runner_4vcpu_ubuntu: "ubuntu-24.04",
|
||||
runner_8vcpu_ubuntu: "blacksmith-8vcpu-ubuntu-2404",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses GitHub-hosted labels outside the canonical repo", () => {
|
||||
expect(
|
||||
selectRunnerLabels({
|
||||
canonicalRepository: false,
|
||||
queuedCountsByLabel: {
|
||||
"blacksmith-4vcpu-ubuntu-2404": 10,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
runner_4vcpu_ubuntu: "ubuntu-24.04",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user