mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
# 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.
This commit is contained in:
@@ -11,75 +11,110 @@ async function getWorkflowMinutes(): Promise<Record<string, number>> {
|
||||
.toISOString()
|
||||
.split('T')[0];
|
||||
|
||||
const output = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'run',
|
||||
'list',
|
||||
'--limit',
|
||||
'1000',
|
||||
'--created',
|
||||
`>=${sevenDaysAgoDate}`,
|
||||
'--json',
|
||||
'databaseId,workflowName',
|
||||
],
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
|
||||
const runs = JSON.parse(output);
|
||||
const workflowMinutes: Record<string, number> = {};
|
||||
const token = execFileSync('gh', ['auth', 'token'], {
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
const repoInfo = JSON.parse(
|
||||
execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner'], {
|
||||
let token: string;
|
||||
let repoName: string;
|
||||
|
||||
try {
|
||||
token = execFileSync('gh', ['auth', 'token'], {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
);
|
||||
const repoName = repoInfo.nameWithOwner;
|
||||
|
||||
const chunkSize = 20;
|
||||
for (let i = 0; i < runs.length; i += chunkSize) {
|
||||
const chunk = runs.slice(i, i + chunkSize);
|
||||
await Promise.all(
|
||||
chunk.map(async (r: { databaseId: number; workflowName?: string }) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.github.com/repos/${repoName}/actions/runs/${r.databaseId}/jobs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) return;
|
||||
|
||||
const { jobs } = await res.json();
|
||||
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.workflowName || 'Unknown';
|
||||
workflowMinutes[name] =
|
||||
(workflowMinutes[name] || 0) + runBillableMinutes;
|
||||
}
|
||||
} catch {
|
||||
// Ignore failures for individual runs
|
||||
}
|
||||
}).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;
|
||||
@@ -94,24 +129,12 @@ async function run() {
|
||||
totalMinutes += minutes;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
metric: 'actions_spend_minutes',
|
||||
value: totalMinutes,
|
||||
timestamp: now,
|
||||
details: workflowMinutes,
|
||||
}),
|
||||
);
|
||||
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();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
metric: `actions_spend_minutes_workflow:${safeName}`,
|
||||
value: minutes,
|
||||
timestamp: now,
|
||||
}),
|
||||
process.stdout.write(
|
||||
`actions_spend_minutes_workflow:${safeName},${minutes}\n`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user