Files
gemini-cli/tools/gemini-cli-bot/metrics/scripts/actions_spend.ts
T
gemini-cli[bot] 1a8c7ec226 # Actions Cost Reduction: CI Matrix and Pulse Optimization
This PR implements several measures to reduce the cost of GitHub Actions usage, focusing on the highest-impact areas identified through real per-workflow minutes consumption analysis.

### Summary of Changes

1.  **CI Matrix Optimization**: Reduced the `test_mac` matrix in `Testing: CI` to only run on Node.js 20.x.
    - **Reason**: macOS runners (especially `macos-latest-large`) are significantly more expensive than Linux runners. Node.js 22.x and 24.x are still covered by the `test_linux` matrix, ensuring core compatibility. OS-specific issues are likely to be caught on the recommended Node.js version (20.x).
    - **Impact**: Expected to reduce Mac runner usage by approximately 66% in the CI pipeline.

2.  **Pulse Workflow Optimization**:
    - Added a check to skip `npm ci` and subsequent steps if no reflex scripts are present in `tools/gemini-cli-bot/reflexes/scripts`.
    - Reduced `fetch-depth` from 0 (full clone) to 1 (shallow clone).
    - **Reason**: The Pulse workflow runs every 30 minutes. Installing dependencies when there is nothing to run is a waste of resources.
    - **Impact**: Eliminates unnecessary dependency installation and reduces clone time for the Pulse workflow.

3.  **Brain Workflow Optimization**:
    - Reduced `fetch-depth` from 0 to 1.
    - **Reason**: The Brain workflow does not require full repository history for its reasoning or metrics collection phases.
    - **Impact**: Reduces clone time for the daily Brain workflow runs.

4.  **Metrics Reporting Fix**:
    - Implemented pagination in `tools/gemini-cli-bot/metrics/scripts/actions_spend.ts` to ensure a full 7-day window is captured.
    - **Reason**: The previous 1000-run limit was causing significant under-reporting for constant-rate workflows like Pulse during CI surges, as the sample was saturated by bursty CI activity.
    - **Impact**: Provides more accurate cost metrics, revealing the true scale of constant "heartbeat" costs.

### Data-Driven Justification

Analysis of the last 7 days of metrics (`actions_spend_minutes`) showed:
- **Testing: CI**: 4074 minutes (approx. 64% of total spend).
- **macOS Runners**: The primary driver of CI cost due to high per-minute rates on large runners.
- **Pulse Workflow**: Previously under-reported due to sampling limits, but now identified as a consistent baseline cost that can be significantly optimized.

These changes prioritize high-impact reductions in expensive runner minutes while maintaining robust cross-platform testing on the primary supported Node.js version.
2026-05-06 00:03:36 +00:00

149 lines
3.9 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
async function getWorkflowMinutes(): Promise<Record<string, number>> {
const sevenDaysAgoDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
const workflowMinutes: Record<string, number> = {};
let token: string;
let repoName: string;
try {
token = execFileSync('gh', ['auth', 'token'], {
encoding: 'utf-8',
}).trim();
const repoInfo = JSON.parse(
execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner'], {
encoding: 'utf-8',
}),
);
repoName = repoInfo.nameWithOwner;
} catch (err) {
throw new Error(`Failed to initialize repository info: ${err}`);
}
let page = 1;
const perPage = 100;
let hasMore = true;
const maxRuns = 5000;
let totalRunsProcessed = 0;
while (hasMore && totalRunsProcessed < maxRuns) {
let output: string;
try {
output = execFileSync(
'gh',
[
'api',
`repos/${repoName}/actions/runs?created=>=${sevenDaysAgoDate}&per_page=${perPage}&page=${page}`,
],
{ encoding: 'utf-8' },
);
} catch (err) {
process.stderr.write(`Failed to fetch page ${page}: ${err}\n`);
break;
}
let workflow_runs: any[];
try {
const parsed = JSON.parse(output);
workflow_runs = parsed.workflow_runs;
} catch (err) {
process.stderr.write(`Failed to parse runs JSON: ${err}\n`);
break;
}
if (!workflow_runs || workflow_runs.length === 0) {
hasMore = false;
break;
}
const chunkSize = 20;
for (let i = 0; i < workflow_runs.length; i += chunkSize) {
const chunk = workflow_runs.slice(i, i + chunkSize);
await Promise.all(
chunk.map(async (r: { id: number; name?: string }) => {
try {
const res = await fetch(
`https://api.github.com/repos/${repoName}/actions/runs/${r.id}/jobs`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
},
},
);
if (!res.ok) return;
const { jobs } = (await res.json()) as { jobs: any[] };
let runBillableMinutes = 0;
for (const job of jobs || []) {
if (!job.started_at || !job.completed_at) continue;
const start = new Date(job.started_at).getTime();
const end = new Date(job.completed_at).getTime();
const durationMs = end - start;
if (durationMs > 0) {
runBillableMinutes += Math.ceil(durationMs / (1000 * 60));
}
}
if (runBillableMinutes > 0) {
const name = r.name || 'Unknown';
workflowMinutes[name] =
(workflowMinutes[name] || 0) + runBillableMinutes;
}
} catch {
// Ignore failures for individual runs
}
}),
);
}
totalRunsProcessed += workflow_runs.length;
if (workflow_runs.length < perPage) {
hasMore = false;
} else {
page++;
}
}
return workflowMinutes;
}
async function run() {
try {
const workflowMinutes = await getWorkflowMinutes();
let totalMinutes = 0;
for (const minutes of Object.values(workflowMinutes)) {
totalMinutes += minutes;
}
process.stdout.write(`actions_spend_minutes,${totalMinutes}\n`);
for (const [name, minutes] of Object.entries(workflowMinutes)) {
const safeName = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
process.stdout.write(
`actions_spend_minutes_workflow:${safeName},${minutes}\n`,
);
}
} catch (error) {
process.stderr.write(
error instanceof Error ? error.message : String(error),
);
process.exit(1);
}
}
run();