diff --git a/.allstar/branch_protection.yaml b/.allstar/branch_protection.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f3d874ba0579000131f9ad94332f67d57ca7b1c0 --- /dev/null +++ b/.allstar/branch_protection.yaml @@ -0,0 +1 @@ +action: 'log' diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000000000000000000000000000000000..0da8dd1a0b0460aafb5d33e0208ded36a1acdd04 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,22 @@ +# By default, require reviews from the maintainers for all files. +* @google-gemini/gemini-cli-maintainers + +# Require reviews from the release approvers for critical files. +# These patterns override the rule above. +/package.json @google-gemini/gemini-cli-askmode-approvers +/package-lock.json @google-gemini/gemini-cli-askmode-approvers +/GEMINI.md @google-gemini/gemini-cli-askmode-approvers +/SECURITY.md @google-gemini/gemini-cli-askmode-approvers +/LICENSE @google-gemini/gemini-cli-askmode-approvers +/.github/workflows/ @google-gemini/gemini-cli-askmode-approvers +/packages/cli/package.json @google-gemini/gemini-cli-askmode-approvers +/packages/core/package.json @google-gemini/gemini-cli-askmode-approvers + +# Docs have a dedicated approver group in addition to maintainers +/docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs +/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs + +# Prompt contents, tool definitions, and evals require reviews from prompt approvers +/packages/core/src/prompts/ @google-gemini/gemini-cli-prompt-approvers +/packages/core/src/tools/ @google-gemini/gemini-cli-prompt-approvers +/evals/ @google-gemini/gemini-cli-prompt-approvers diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000000000000000000000000000000000..9dfbe5b1607424d60801212f6c0e7f0bee85b864 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: 'Bug Report' +description: 'Report a bug to help us improve Gemini CLI' +body: + - type: 'markdown' + attributes: + value: |- + > [!IMPORTANT] + > Thanks for taking the time to fill out this bug report! + > + > Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues)** to see if an issue already exists for the bug you encountered. + + - type: 'textarea' + id: 'problem' + attributes: + label: 'What happened?' + description: 'A clear and concise description of what the bug is.' + validations: + required: true + + - type: 'textarea' + id: 'expected' + attributes: + label: 'What did you expect to happen?' + validations: + required: true + + - type: 'textarea' + id: 'info' + attributes: + label: 'Client information' + description: 'Please paste the full text from the `/about` command run from Gemini CLI. Also include which platform (macOS, Windows, Linux). Note that this output contains your email address. Consider removing it before submitting.' + value: |- +
+ Client Information + + Run `gemini` to enter the interactive CLI, then run the `/about` command. + + ```console + > /about + # paste output here + ``` + +
+ validations: + required: true + + - type: 'textarea' + id: 'login-info' + attributes: + label: 'Login information' + description: 'Describe how you are logging in (e.g., Google Account, API key).' + + - type: 'textarea' + id: 'additional-context' + attributes: + label: 'Anything else we need to know?' + description: 'Add any other context about the problem here.' diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000000000000000000000000000000000..fe29c794ffd0a998366a1988a6f227cef047151e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: 'Feature Request' +description: 'Suggest an idea for this project' +labels: + - 'status/need-triage' +type: 'Feature' +body: + - type: 'markdown' + attributes: + value: |- + > [!IMPORTANT] + > Thanks for taking the time to suggest an enhancement! + > + > Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues)** to see if a similar feature has already been requested. + + - type: 'textarea' + id: 'feature' + attributes: + label: 'What would you like to be added?' + description: 'A clear and concise description of the enhancement.' + validations: + required: true + + - type: 'textarea' + id: 'rationale' + attributes: + label: 'Why is this needed?' + description: 'A clear and concise description of why this enhancement is needed.' + validations: + required: true + + - type: 'textarea' + id: 'additional-context' + attributes: + label: 'Additional context' + description: 'Add any other context or screenshots about the feature request here.' diff --git a/.github/ISSUE_TEMPLATE/website_issue.yml b/.github/ISSUE_TEMPLATE/website_issue.yml new file mode 100644 index 0000000000000000000000000000000000000000..d9b30e1127ca70c1fc093d33bc6cb32417a632e4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/website_issue.yml @@ -0,0 +1,42 @@ +name: 'Website issue' +description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery' +title: 'GeminiCLI.com Feedback: [ISSUE]' +labels: + - 'area/extensions' + - 'area/documentation' +body: + - type: 'markdown' + attributes: + value: |- + > [!IMPORTANT] + > Thanks for taking the time to report an issue with the Gemini CLI Website + > + > Please search **[existing issues](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3Aarea%2Fwebsite)** to see if a similar feature has already been requested. + - type: 'input' + id: 'url' + attributes: + label: 'URL of the page with the issue' + description: 'Please provide the URL where the issue occurs.' + validations: + required: true + + - type: 'textarea' + id: 'problem' + attributes: + label: 'What is the problem?' + description: 'A clear and concise description of what the bug or issue is.' + validations: + required: true + + - type: 'textarea' + id: 'expected' + attributes: + label: 'What did you expect to happen?' + validations: + required: true + + - type: 'textarea' + id: 'additional-context' + attributes: + label: 'Additional context' + description: 'Add any other context or screenshots about the issue here.' diff --git a/.github/actions/calculate-vars/action.yml b/.github/actions/calculate-vars/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..e5a5ac5318e505edc3412dc6175ca81983d5345a --- /dev/null +++ b/.github/actions/calculate-vars/action.yml @@ -0,0 +1,27 @@ +name: 'Calculate vars' +description: 'Calculate commonly used var in our release process' + +inputs: + dry_run: + description: 'Whether or not this is a dry run' + type: 'boolean' + +outputs: + is_dry_run: + description: 'Boolean flag indicating if the current run is a dry-run or a production release.' + value: '${{ steps.set_vars.outputs.is_dry_run }}' + +runs: + using: 'composite' + steps: + - name: 'Set vars for simplified logic' + id: 'set_vars' + shell: 'bash' + env: + DRY_RUN_INPUT: '${{ inputs.dry_run }}' + run: |- + is_dry_run="true" + if [[ "${DRY_RUN_INPUT}" == "" || "${DRY_RUN_INPUT}" == "false" ]]; then + is_dry_run="false" + fi + echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}" diff --git a/.github/actions/download-mac-binaries/action.yml b/.github/actions/download-mac-binaries/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..af0fb511e7b16b57849d8c3eae6bfa1ae3c981bf --- /dev/null +++ b/.github/actions/download-mac-binaries/action.yml @@ -0,0 +1,23 @@ +name: 'Download Mac Binaries' +description: 'Downloads the unsigned macOS binaries (x64 and arm64)' +inputs: + path: + description: 'The base path to download the binaries to' + required: true + default: 'dist' +runs: + using: 'composite' + steps: + - name: 'Download macOS arm64 binary' + uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4 + continue-on-error: true + with: + name: 'gemini-darwin-arm64-unsigned' + path: '${{ inputs.path }}/darwin-arm64' + + - name: 'Download macOS x64 binary' + uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4 + continue-on-error: true + with: + name: 'gemini-darwin-x64-unsigned' + path: '${{ inputs.path }}/darwin-x64' diff --git a/.github/actions/npm-auth-token/action.yml b/.github/actions/npm-auth-token/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..f9fe4bd894e11ba02dcf3535b3edc818df32b264 --- /dev/null +++ b/.github/actions/npm-auth-token/action.yml @@ -0,0 +1,51 @@ +name: 'NPM Auth Token' +description: 'Generates an NPM auth token for publishing a specific package' + +inputs: + package-name: + description: 'The name of the package to publish' + required: true + github-token: + description: 'the github token' + required: true + wombat-token-core: + description: 'The npm token for the cli-core package.' + required: true + wombat-token-cli: + description: 'The npm token for the cli package.' + required: true + wombat-token-a2a-server: + description: 'The npm token for the a2a package.' + required: true + +outputs: + auth-token: + description: 'The generated NPM auth token' + value: '${{ steps.npm_auth_token.outputs.auth-token }}' + +runs: + using: 'composite' + steps: + - name: 'Generate NPM Auth Token' + id: 'npm_auth_token' + shell: 'bash' + run: | + AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" + PACKAGE_NAME="${INPUTS_PACKAGE_NAME}" + PRIVATE_REPO="@google-gemini/" + if [[ "$PACKAGE_NAME" == "$PRIVATE_REPO"* ]]; then + AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" + elif [[ "$PACKAGE_NAME" == "@google/gemini-cli" ]]; then + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CLI}" + elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-core" ]]; then + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CORE}" + elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-a2a-server" ]]; then + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_A2A_SERVER}" + fi + echo "auth-token=$AUTH_TOKEN" >> $GITHUB_OUTPUT + env: + INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' + INPUTS_PACKAGE_NAME: '${{ inputs.package-name }}' + INPUTS_WOMBAT_TOKEN_CLI: '${{ inputs.wombat-token-cli }}' + INPUTS_WOMBAT_TOKEN_CORE: '${{ inputs.wombat-token-core }}' + INPUTS_WOMBAT_TOKEN_A2A_SERVER: '${{ inputs.wombat-token-a2a-server }}' diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000000000000000000000000000000..ddb5b7d283ccb150196511b80b5d10b8af6ea130 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 +updates: + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + open-pull-requests-limit: 10 + reviewers: + - 'joshualitt' + cooldown: + semver-major-days: 14 + semver-minor-days: 14 + semver-patch-days: 14 + groups: + npm-dependencies: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'weekly' + day: 'monday' + open-pull-requests-limit: 10 + reviewers: + - 'joshualitt' + cooldown: + semver-major-days: 14 + semver-minor-days: 14 + semver-patch-days: 14 + groups: + actions-dependencies: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000000000000000000000000000000..37d896381d74ef32cc20283bd71e0264d0a38738 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,42 @@ +## Summary + + + +## Details + + + +## Related Issues + + + +## How to Validate + + + +## Pre-Merge Checklist + + + +- [ ] Updated relevant documentation and README (if needed) +- [ ] Added/updated tests (if needed) +- [ ] Noted breaking changes (if any) +- [ ] Validated on required platforms/methods: + - [ ] MacOS + - [ ] npm run + - [ ] npx + - [ ] Docker + - [ ] Podman + - [ ] Seatbelt + - [ ] Windows + - [ ] npm run + - [ ] npx + - [ ] Docker + - [ ] Linux + - [ ] npm run + - [ ] npx + - [ ] Docker diff --git a/.github/scripts/apply-issue-labels.cjs b/.github/scripts/apply-issue-labels.cjs new file mode 100644 index 0000000000000000000000000000000000000000..d0a82ab1f06509c0d07cf623aa9896f5bcdc3989 --- /dev/null +++ b/.github/scripts/apply-issue-labels.cjs @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +module.exports = async ({ github, context, core }) => { + const extractJson = (raw) => { + if (!raw || raw === '[]' || raw === '') return []; + try { + // First, try to parse the raw output as JSON. + return JSON.parse(raw); + } catch { + // If that fails, check for a markdown code block. + core.info( + 'Direct JSON parsing failed. Trying to extract from a markdown block.', + ); + const jsonMatch = raw.match(/```json\s*([\s\S]*?)\s*```/); + if (jsonMatch && jsonMatch[1]) { + try { + return JSON.parse(jsonMatch[1].trim()); + } catch (markdownError) { + core.warning( + `Failed to parse extracted JSON from markdown block: ${markdownError.message}`, + ); + } + } + + // Try to find a raw JSON array in the output. + const jsonArrayMatch = raw.match( + /\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/, + ); + if (jsonArrayMatch) { + try { + return JSON.parse(jsonArrayMatch[0]); + } catch { + const fallbackMatch = raw.match(/(\[\s*\{\s*"issue_number"[\s\S]*)/); + if (fallbackMatch) { + try { + const cleaned = fallbackMatch[0].substring( + 0, + fallbackMatch[0].lastIndexOf(']') + 1, + ); + return JSON.parse(cleaned); + } catch (fallbackError) { + core.warning( + `Failed to parse extracted JSON using fallback regex: ${fallbackError.message}`, + ); + } + } + } + } + } + core.warning('No valid JSON could be extracted from input.'); + return []; + }; + + // Collect all outputs from environment variables + // Prioritize EFFORT results over STANDARD results by processing Effort FIRST + // so that its labels appear first in the merged arrays (and thus win in mutually exclusive logic) + const effortRaw = process.env.LABELS_OUTPUT_EFFORT; + const standardRaw = process.env.LABELS_OUTPUT_STANDARD; + const genericRaw = process.env.LABELS_OUTPUT; + + const resultsByIssue = new Map(); + + const processResults = (results, _sourceName) => { + for (const entry of results) { + const issueNumber = entry.issue_number; + if (!issueNumber) continue; + + if (!resultsByIssue.has(issueNumber)) { + resultsByIssue.set(issueNumber, { + issue_number: issueNumber, + labels_to_add: [...(entry.labels_to_add || [])], + labels_to_remove: [...(entry.labels_to_remove || [])], + explanation: entry.explanation || '', + effort_analysis: entry.effort_analysis || '', + }); + } else { + const existing = resultsByIssue.get(issueNumber); + // Combine labels + existing.labels_to_add = [ + ...new Set([ + ...existing.labels_to_add, + ...(entry.labels_to_add || []), + ]), + ]; + existing.labels_to_remove = [ + ...new Set([ + ...existing.labels_to_remove, + ...(entry.labels_to_remove || []), + ]), + ]; + + // Combine explanations (if different) + if ( + entry.explanation && + !existing.explanation.includes(entry.explanation) + ) { + existing.explanation = existing.explanation + ? `${existing.explanation}\n\n${entry.explanation}` + : entry.explanation; + } + + // Take effort analysis if present + if (entry.effort_analysis && !existing.effort_analysis) { + existing.effort_analysis = entry.effort_analysis; + } + } + } + }; + + // Order matters: Effort first so its labels win in conflict resolution + processResults(extractJson(effortRaw), 'EFFORT'); + processResults(extractJson(standardRaw), 'STANDARD'); + processResults(extractJson(genericRaw), 'GENERIC'); + + const finalResults = Array.from(resultsByIssue.values()); + core.info(`Aggregated triage results for ${finalResults.length} issues.`); + + for (const entry of finalResults) { + const issueNumber = entry.issue_number; + let labelsToAdd = entry.labels_to_add || []; + let labelsToRemove = entry.labels_to_remove || []; + let existingLabels = []; + + // Fetch existing labels early + try { + const { data: issueData } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + existingLabels = issueData.labels.map((l) => + typeof l === 'string' ? l : l.name, + ); + } catch (e) { + core.warning( + `Failed to fetch existing labels for #${issueNumber}: ${e.message}`, + ); + } + + // Programmatic Priority Downgrade Logic + if (labelsToAdd.includes('status/need-information')) { + const targetPriority = labelsToAdd.find((l) => l.startsWith('priority/')); + if (targetPriority) { + let downgradedPriority = null; + if (targetPriority === 'priority/p0') + downgradedPriority = 'priority/p1'; + if (targetPriority === 'priority/p1') + downgradedPriority = 'priority/p2'; + + if (downgradedPriority) { + core.info( + `Programmatically downgrading ${targetPriority} to ${downgradedPriority} due to status/need-information`, + ); + labelsToAdd = labelsToAdd.filter((l) => l !== targetPriority); + labelsToAdd.push(downgradedPriority); + } + } + } + + labelsToRemove.push('status/need-triage'); + + if ( + labelsToAdd.includes('status/manual-triage') || + existingLabels.includes('status/manual-triage') + ) { + labelsToRemove.push('status/bot-triaged'); + labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged'); + } else { + labelsToAdd.push('status/bot-triaged'); + } + + // Resolve internal conflicts (e.g., adding P1 and P2) + // We already resolved these by putting Effort first in the combined list + + // Resolve external conflicts with existing labels + if (labelsToAdd.some((l) => l.startsWith('area/'))) { + labelsToRemove.push( + ...existingLabels.filter((l) => l.startsWith('area/')), + ); + } + if (labelsToAdd.some((l) => l.startsWith('priority/'))) { + labelsToRemove.push( + ...existingLabels.filter((l) => l.startsWith('priority/')), + ); + } + if (labelsToAdd.some((l) => l.startsWith('kind/'))) { + labelsToRemove.push( + ...existingLabels.filter((l) => l.startsWith('kind/')), + ); + } + + // Enforce mutual exclusivity in the TO-ADD list (Architect wins) + const exclusivePrefixes = ['area/', 'priority/', 'kind/']; + for (const prefix of exclusivePrefixes) { + const filtered = labelsToAdd.filter((l) => l.startsWith(prefix)); + if (filtered.length > 1) { + const winner = filtered[0]; // First one wins + core.info( + `Issue #${issueNumber} has multiple ${prefix} labels suggested. Keeping "${winner}" and discarding others.`, + ); + labelsToAdd = labelsToAdd.filter( + (l) => !l.startsWith(prefix) || l === winner, + ); + } + } + + // Final deduplication and cleanup + labelsToRemove = [...new Set(labelsToRemove)].filter( + (l) => !labelsToAdd.includes(l) && existingLabels.includes(l), + ); + labelsToAdd = [...new Set(labelsToAdd)].filter( + (l) => !existingLabels.includes(l), + ); + + // Batch label operations + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: labelsToAdd, + }); + core.info( + `Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`, + ); + } + + if (labelsToRemove.length > 0) { + for (const label of labelsToRemove) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label, + }); + } catch (e) { + if (e.status !== 404) + core.warning( + `Failed to remove label ${label} from #${issueNumber}: ${e.message}`, + ); + } + } + core.info( + `Successfully removed labels for #${issueNumber}: ${labelsToRemove.join(', ')}`, + ); + } + + // Post comment if needed + const needsInfoAdded = + labelsToAdd.includes('status/need-information') && + !existingLabels.includes('status/need-information'); + const hasEffortAnalysis = !!entry.effort_analysis; + + if (needsInfoAdded || hasEffortAnalysis) { + let commentBody = ''; + if (needsInfoAdded && entry.explanation) commentBody += entry.explanation; + if (hasEffortAnalysis) { + if (commentBody) commentBody += '\n\n'; + commentBody += `**Effort Analysis:**\n${entry.effort_analysis}`; + } + + if (commentBody) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: commentBody, + }); + core.info(`Posted required comment for #${issueNumber}`); + } + } + } +}; diff --git a/.github/scripts/backfill-need-triage.cjs b/.github/scripts/backfill-need-triage.cjs new file mode 100644 index 0000000000000000000000000000000000000000..e6213965283258a2da9984db9057f1bf7d4d2e8e --- /dev/null +++ b/.github/scripts/backfill-need-triage.cjs @@ -0,0 +1,138 @@ +/* eslint-disable */ +/* global require, console, process */ + +/** + * Script to backfill the 'status/need-triage' label to all open issues + * that are NOT currently labeled with '๐Ÿ”’ maintainer only' or 'help wanted'. + */ + +const { execFileSync } = require('child_process'); + +const isDryRun = process.argv.includes('--dry-run'); +const REPO = 'google-gemini/gemini-cli'; + +/** + * Executes a GitHub CLI command safely using an argument array to prevent command injection. + * @param {string[]} args + * @returns {string|null} + */ +function runGh(args) { + try { + // Using execFileSync with an array of arguments is safe as it doesn't use a shell. + // We set a large maxBuffer (10MB) to handle repositories with many issues. + return execFileSync('gh', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; + console.error( + `โŒ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, + ); + return null; + } +} + +async function main() { + console.log('๐Ÿ” GitHub CLI security check...'); + const authStatus = runGh(['auth', 'status']); + if (authStatus === null) { + console.error('โŒ GitHub CLI (gh) is not installed or not authenticated.'); + process.exit(1); + } + + if (isDryRun) { + console.log('๐Ÿงช DRY RUN MODE ENABLED - No changes will be made.\n'); + } + + console.log(`๐Ÿ” Fetching and filtering open issues from ${REPO}...`); + + // We use the /issues endpoint with pagination to bypass the 1000-result limit. + // The jq filter ensures we exclude PRs, maintainer-only, help-wanted, and existing status/need-triage. + const jqFilter = + '.[] | select(.pull_request == null) | select([.labels[].name] as $l | (any($l[]; . == "๐Ÿ”’ maintainer only") | not) and (any($l[]; . == "help wanted") | not) and (any($l[]; . == "status/need-triage") | not)) | {number: .number, title: .title}'; + + const output = runGh([ + 'api', + `repos/${REPO}/issues?state=open&per_page=100`, + '--paginate', + '--jq', + jqFilter, + ]); + + if (output === null) { + process.exit(1); + } + + const issues = output + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line); + } catch (_e) { + console.error(`โš ๏ธ Failed to parse line: ${line}`); + return null; + } + }) + .filter(Boolean); + + console.log(`โœ… Found ${issues.length} issues matching criteria.`); + + if (issues.length === 0) { + console.log('โœจ No issues need backfilling.'); + return; + } + + let successCount = 0; + let failCount = 0; + + if (isDryRun) { + for (const issue of issues) { + console.log( + `[DRY RUN] Would label issue #${issue.number}: ${issue.title}`, + ); + } + successCount = issues.length; + } else { + console.log(`๐Ÿท๏ธ Applying labels to ${issues.length} issues...`); + + for (const issue of issues) { + const issueNumber = String(issue.number); + console.log(`๐Ÿท๏ธ Labeling issue #${issueNumber}: ${issue.title}`); + + const result = runGh([ + 'issue', + 'edit', + issueNumber, + '--add-label', + 'status/need-triage', + '--repo', + REPO, + ]); + + if (result !== null) { + successCount++; + } else { + failCount++; + } + } + } + + console.log(`\n๐Ÿ“Š Summary:`); + console.log(` - Success: ${successCount}`); + console.log(` - Failed: ${failCount}`); + + if (failCount > 0) { + console.error(`\nโŒ Backfill completed with ${failCount} errors.`); + process.exit(1); + } else { + console.log(`\n๐ŸŽ‰ ${isDryRun ? 'Dry run' : 'Backfill'} complete!`); + } +} + +main().catch((error) => { + console.error('โŒ Unexpected error:', error); + process.exit(1); +}); diff --git a/.github/scripts/backfill-pr-notification.cjs b/.github/scripts/backfill-pr-notification.cjs new file mode 100644 index 0000000000000000000000000000000000000000..3014398519763543eb245b689c02736bd29f76ce --- /dev/null +++ b/.github/scripts/backfill-pr-notification.cjs @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable */ +/* global require, console, process */ + +/** + * Script to backfill a process change notification comment to all open PRs + * not created by members of the 'gemini-cli-maintainers' team. + * + * Skip PRs that are already associated with an issue. + */ + +const { execFileSync } = require('child_process'); + +const isDryRun = process.argv.includes('--dry-run'); +const REPO = 'google-gemini/gemini-cli'; +const ORG = 'google-gemini'; +const TEAM_SLUG = 'gemini-cli-maintainers'; +const DISCUSSION_URL = + 'https://github.com/google-gemini/gemini-cli/discussions/16706'; + +/** + * Executes a GitHub CLI command safely using an argument array. + */ +function runGh(args, options = {}) { + const { silent = false } = options; + try { + return execFileSync('gh', args, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); + } catch (error) { + if (!silent) { + const stderr = error.stderr ? ` Stderr: ${error.stderr.trim()}` : ''; + console.error( + `โŒ Error running gh ${args.join(' ')}: ${error.message}${stderr}`, + ); + } + return null; + } +} + +/** + * Checks if a user is a member of the maintainers team. + */ +const membershipCache = new Map(); +function isMaintainer(username) { + if (membershipCache.has(username)) return membershipCache.get(username); + + // GitHub returns 404 if user is not a member. + // We use silent: true to avoid logging 404s as errors. + const result = runGh( + ['api', `orgs/${ORG}/teams/${TEAM_SLUG}/memberships/${username}`], + { silent: true }, + ); + + const isMember = result !== null; + membershipCache.set(username, isMember); + return isMember; +} + +async function main() { + console.log('๐Ÿ” GitHub CLI security check...'); + if (runGh(['auth', 'status']) === null) { + console.error('โŒ GitHub CLI (gh) is not authenticated.'); + process.exit(1); + } + + if (isDryRun) { + console.log('๐Ÿงช DRY RUN MODE ENABLED\n'); + } + + console.log(`๐Ÿ“ฅ Fetching open PRs from ${REPO}...`); + // Fetch number, author, and closingIssuesReferences to check if linked to an issue + const prsJson = runGh([ + 'pr', + 'list', + '--repo', + REPO, + '--state', + 'open', + '--limit', + '1000', + '--json', + 'number,author,closingIssuesReferences', + ]); + + if (prsJson === null) process.exit(1); + const prs = JSON.parse(prsJson); + + console.log(`๐Ÿ“Š Found ${prs.length} open PRs. Filtering...`); + + let targetPrs = []; + for (const pr of prs) { + const author = pr.author.login; + const issueCount = pr.closingIssuesReferences + ? pr.closingIssuesReferences.length + : 0; + + if (issueCount > 0) { + // Skip if already linked to an issue + continue; + } + + if (!isMaintainer(author)) { + targetPrs.push(pr); + } + } + + console.log( + `โœ… Found ${targetPrs.length} PRs from non-maintainers without associated issues.`, + ); + + const commentBody = + "\nHi @{AUTHOR}, thank you so much for your contribution to Gemini CLI! We really appreciate the time and effort you've put into this.\n\nWe're making some updates to our contribution process to improve how we track and review changes. Please take a moment to review our recent discussion post: [Improving Our Contribution Process & Introducing New Guidelines](${DISCUSSION_URL}).\n\nKey Update: Starting **January 26, 2026**, the Gemini CLI project will require all pull requests to be associated with an existing issue. Any pull requests not linked to an issue by that date will be automatically closed.\n\nThank you for your understanding and for being a part of our community!\n ".trim(); + + let successCount = 0; + let skipCount = 0; + let failCount = 0; + + for (const pr of targetPrs) { + const prNumber = String(pr.number); + const author = pr.author.login; + + // Check if we already commented (idempotency) + // We use silent: true here because view might fail if PR is deleted mid-run + const existingComments = runGh( + [ + 'pr', + 'view', + prNumber, + '--repo', + REPO, + '--json', + 'comments', + '--jq', + `.comments[].body | contains("${DISCUSSION_URL}")`, + ], + { silent: true }, + ); + + if (existingComments && existingComments.includes('true')) { + console.log( + `โญ๏ธ PR #${prNumber} already has the notification. Skipping.`, + ); + skipCount++; + continue; + } + + if (isDryRun) { + console.log(`[DRY RUN] Would notify @${author} on PR #${prNumber}`); + successCount++; + } else { + console.log(`๐Ÿ’ฌ Notifying @${author} on PR #${prNumber}...`); + const personalizedComment = commentBody.replace('{AUTHOR}', author); + const result = runGh([ + 'pr', + 'comment', + prNumber, + '--repo', + REPO, + '--body', + personalizedComment, + ]); + + if (result !== null) { + successCount++; + } else { + failCount++; + } + } + } + + console.log(`\n๐Ÿ“Š Summary:`); + console.log(` - Notified: ${successCount}`); + console.log(` - Skipped: ${skipCount}`); + console.log(` - Failed: ${failCount}`); + + if (failCount > 0) process.exit(1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.github/scripts/cleanup-triage-labels.cjs b/.github/scripts/cleanup-triage-labels.cjs new file mode 100644 index 0000000000000000000000000000000000000000..390f018bd862e971551bdb4593a788ace2e7042c --- /dev/null +++ b/.github/scripts/cleanup-triage-labels.cjs @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const fs = require('node:fs'); + +module.exports = async ({ github, context, core }) => { + let issuesToCleanup = []; + try { + const fileContent = fs.readFileSync('issues_to_cleanup.json', 'utf8'); + issuesToCleanup = JSON.parse(fileContent); + } catch (error) { + if (error.code === 'ENOENT') { + core.info('No issues found to clean up.'); + return; + } + core.setFailed(`Failed to read issues_to_cleanup.json: ${error.message}`); + return; + } + + for (const issue of issuesToCleanup) { + try { + const { data: issueData } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + }); + + const labels = issueData.labels.map((l) => + typeof l === 'string' ? l : l.name, + ); + + if ( + labels.includes('status/bot-triaged') && + labels.includes('status/need-triage') + ) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: 'status/need-triage', + }); + core.info( + `Successfully removed status/need-triage from #${issue.number}`, + ); + } + + if ( + labels.includes('status/bot-triaged') && + labels.includes('status/manual-triage') + ) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: 'status/bot-triaged', + }); + core.info( + `Successfully removed status/bot-triaged from #${issue.number} because it requires manual triage`, + ); + } + } catch (error) { + core.warning( + `Failed to clean up labels for #${issue.number}: ${error.message}`, + ); + } + } + + core.info( + `Cleaned up conflicting labels from ${issuesToCleanup.length} issues.`, + ); +}; diff --git a/.github/scripts/gemini-lifecycle-manager.cjs b/.github/scripts/gemini-lifecycle-manager.cjs new file mode 100644 index 0000000000000000000000000000000000000000..c69f35706d11c534d1549fe2011cfb1a6d6c5e39 --- /dev/null +++ b/.github/scripts/gemini-lifecycle-manager.cjs @@ -0,0 +1,380 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Gemini Scheduled Lifecycle Manager Script + * @param {object} param0 + * @param {import('@octokit/rest').Octokit} param0.github + * @param {import('@actions/github/lib/context').Context} param0.context + * @param {import('@actions/core')} param0.core + */ +module.exports = async ({ github, context, core }) => { + const dryRun = process.env.DRY_RUN === 'true'; + const owner = context.repo.owner; + const repo = context.repo.repo; + + core.info(`Running in ${dryRun ? 'DRY RUN' : 'PRODUCTION'} mode.`); + + const STALE_LABEL = 'stale'; + const NEED_INFO_LABEL = 'status/need-information'; + const EXEMPT_LABELS = [ + 'pinned', + 'security', + '๐Ÿ”’ maintainer only', + 'help wanted', + '๐Ÿ—“๏ธ Public Roadmap', + ]; + + const STALE_DAYS = 60; + const CLOSE_DAYS = 14; + const NO_RESPONSE_DAYS = 14; + + const now = new Date(); + const staleThreshold = new Date( + now.getTime() - STALE_DAYS * 24 * 60 * 60 * 1000, + ); + const closeThreshold = new Date( + now.getTime() - CLOSE_DAYS * 24 * 60 * 60 * 1000, + ); + const noResponseThreshold = new Date( + now.getTime() - NO_RESPONSE_DAYS * 24 * 60 * 60 * 1000, + ); + + const maintainerCache = new Map(); + async function isMaintainer(user, association) { + if (user?.type === 'Bot') return true; + if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association)) return true; + + const username = user?.login; + if (!username) return false; + + if (maintainerCache.has(username)) { + return maintainerCache.get(username); + } + + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + // Permission can be admin, write, read, none. + // Roles like 'maintain' or 'triage' often map to 'write' or 'read' in the top-level field. + const isM = + ['admin', 'write'].includes(data.permission) || + ['admin', 'maintain', 'write'].includes(data.role_name); + + maintainerCache.set(username, isM); + return isM; + } catch (err) { + core.warning( + `Could not check permissions for ${username}: ${err.message}`, + ); + maintainerCache.set(username, false); + return false; + } + } + + async function processItems(query, callback) { + core.info(`Searching: ${query}`); + try { + let items = await github.paginate( + github.rest.search.issuesAndPullRequests, + { + q: query, + per_page: 100, + sort: 'updated', + order: 'asc', + }, + ); + core.info(`Found ${items.length} items.`); + for (const item of items) { + try { + await callback(item); + } catch (err) { + core.error(`Error processing #${item.number}: ${err.message}`); + } + } + } catch (err) { + core.error(`Search failed: ${err.message}`); + } + } + + // 1. Handle No-Response (status/need-information) + // Removal: Check issues updated in the last 48h that have the label + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + await processItems( + `repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:>${twoDaysAgo.toISOString()}`, + async (item) => { + const { data: comments } = await github.rest.issues.listComments({ + owner, + repo, + issue_number: item.number, + sort: 'created', + direction: 'desc', + per_page: 5, + }); + + // Check if the last comment is from a non-maintainer and not a bot + const lastComment = comments[0]; + if ( + lastComment && + lastComment.user?.type !== 'Bot' && + !(await isMaintainer(lastComment.user, lastComment.author_association)) + ) { + if (dryRun) { + core.info( + `[DRY RUN] Would remove ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`, + ); + } else { + core.info( + `Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`, + ); + await github.rest.issues + .removeLabel({ + owner, + repo, + issue_number: item.number, + name: NEED_INFO_LABEL, + }) + .catch(() => {}); + } + } + }, + ); + + // Closure: Check issues with the label that haven't been updated in 14 days + await processItems( + `repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`, + async (item) => { + if (dryRun) { + core.info( + `[DRY RUN] Would close #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`, + ); + } else { + core.info( + `Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`, + ); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: `This item was marked as needing more information and has not received a response in ${NO_RESPONSE_DAYS} days. Closing it for now. If you still face this problem, feel free to reopen with more details. Thank you!`, + }); + await github.rest.issues.update({ + owner, + repo, + issue_number: item.number, + state: 'closed', + state_reason: 'not_planned', + }); + } + }, + ); + + // 2. Handle Stale Mark (60 days inactivity, no stale label) + const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' '); + + await processItems( + `repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`, + async (item) => { + const isBug = item.labels.some((l) => + (typeof l === 'string' ? l : l.name).toLowerCase().includes('bug'), + ); + const bodyText = isBug + ? `This bug report has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. Many issues are resolved in newer releases. Please verify if the issue persists in the latest Gemini CLI version. If it does, please leave a comment to keep this open. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!` + : `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`; + + if (dryRun) { + core.info(`[DRY RUN] Would mark #${item.number} as stale.`); + } else { + core.info(`Marking #${item.number} as stale.`); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: item.number, + labels: [STALE_LABEL], + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: bodyText, + }); + } + }, + ); + + // 3. Handle Stale Removal & Close + await processItems( + `repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery}`, + async (item) => { + // Fetch full timeline to see events and comments + const timeline = await github.paginate( + github.rest.issues.listEventsForTimeline, + { + owner, + repo, + issue_number: item.number, + per_page: 100, + }, + ); + + // Find exactly when the Stale label was added + // We look for the last 'labeled' event for STALE_LABEL + const staleEventIndex = timeline.findLastIndex( + (e) => + e.event === 'labeled' && + e.label?.name?.toLowerCase() === STALE_LABEL.toLowerCase(), + ); + + if (staleEventIndex === -1) return; // Fallback if no event found + + const staleEvent = timeline[staleEventIndex]; + const eventsAfterStale = timeline.slice(staleEventIndex + 1); + + // Check for meaningful activity after the Stale label was applied + const meaningfulEvents = eventsAfterStale.filter((e) => { + const actor = e.actor?.login || ''; + const isBot = + actor.includes('[bot]') || actor.includes('github-actions'); + + if (isBot) return false; + + // Explicit whitelist of meaningful events for humans + if ( + [ + 'commented', + 'cross-referenced', + 'connected', + 'reopened', + 'assigned', + ].includes(e.event) + ) { + return true; + } + + return false; + }); + + if (meaningfulEvents.length > 0) { + // Activity detected, remove Stale label + if (dryRun) { + core.info( + `[DRY RUN] Would remove ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`, + ); + } else { + core.info( + `Removing ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`, + ); + await github.rest.issues + .removeLabel({ + owner, + repo, + issue_number: item.number, + name: STALE_LABEL, + }) + .catch(() => {}); + } + return; + } + + // No meaningful activity. Check if 14 days have passed. + const labeledDate = new Date(staleEvent.created_at); + if (labeledDate > closeThreshold) { + // Has not been 14 days since it was ACTUALLY marked stale + return; + } + + if (dryRun) { + core.info(`[DRY RUN] Would close stale item #${item.number}.`); + } else { + core.info(`Closing stale item #${item.number}.`); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: item.number, + body: `This item has been closed due to ${CLOSE_DAYS} additional days of inactivity after being marked as stale. If you believe this is still relevant, feel free to comment or reopen. Thank you!`, + }); + await github.rest.issues.update({ + owner, + repo, + issue_number: item.number, + state: 'closed', + state_reason: 'not_planned', + }); + } + }, + ); + + // 4. Handle PR Contribution Policy (Nudge at 7d, Close at 14d) + const PR_NUDGE_DAYS = 7; + const PR_CLOSE_DAYS = 14; + const nudgeThreshold = new Date( + now.getTime() - PR_NUDGE_DAYS * 24 * 60 * 60 * 1000, + ); + const prCloseThreshold = new Date( + now.getTime() - PR_CLOSE_DAYS * 24 * 60 * 60 * 1000, + ); + + // Nudge + await processItems( + `repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"๐Ÿ”’ maintainer only" -label:"status/pr-nudge-sent" created:${prCloseThreshold.toISOString()}..${nudgeThreshold.toISOString()}`, + async (pr) => { + if (await isMaintainer(pr.user, pr.author_association)) return; + + if (dryRun) { + core.info( + `[DRY RUN] Would nudge PR #${pr.number} for contribution policy.`, + ); + } else { + core.info(`Nudging PR #${pr.number} for contribution policy.`); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: ['status/pr-nudge-sent'], + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: "Hi there! Thank you for your interest in contributing to Gemini CLI. \n\nTo ensure we maintain high code quality and focus on our prioritized roadmap, we only guarantee review and consideration of pull requests for issues that are explicitly labeled as 'help wanted'. \n\nThis PR will be closed in 7 days if it remains without that designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.", + }); + } + }, + ); + + // Close + await processItems( + `repo:${owner}/${repo} is:open is:pr -label:"help wanted" -label:"๐Ÿ”’ maintainer only" created:<${prCloseThreshold.toISOString()}`, + async (pr) => { + if (await isMaintainer(pr.user, pr.author_association)) return; + + if (dryRun) { + core.info( + `[DRY RUN] Would close PR #${pr.number} per contribution policy (no 'help wanted').`, + ); + } else { + core.info( + `Closing PR #${pr.number} per contribution policy (no 'help wanted').`, + ); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: "This pull request is being closed as it has been open for 14 days without a 'help wanted' designation. We encourage you to find and contribute to existing 'help wanted' issues in our backlog! Thank you for your understanding.", + }); + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); + } + }, + ); +}; diff --git a/.github/scripts/pr-triage.sh b/.github/scripts/pr-triage.sh new file mode 100644 index 0000000000000000000000000000000000000000..92200ee4d25f117996272bafe3b0cc8cd255a01e --- /dev/null +++ b/.github/scripts/pr-triage.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# @license +# Copyright 2026 Google LLC +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +# Initialize a comma-separated string to hold PR numbers that need a comment +PRS_NEEDING_COMMENT="" + +# Global cache for issue labels (compatible with Bash 3.2) +# Stores "|ISSUE_NUM:LABELS|" segments +ISSUE_LABELS_CACHE_FLAT="|" + +# Function to get labels from an issue (with caching) +get_issue_labels() { + local ISSUE_NUM="${1}" + if [[ -z "${ISSUE_NUM}" || "${ISSUE_NUM}" == "null" || "${ISSUE_NUM}" == "" ]]; then + return + fi + + # Check cache + case "${ISSUE_LABELS_CACHE_FLAT}" in + *"|${ISSUE_NUM}:"*) + local suffix="${ISSUE_LABELS_CACHE_FLAT#*|"${ISSUE_NUM}":}" + echo "${suffix%%|*}" + return + ;; + *) + # Cache miss, proceed to fetch + ;; + esac + + echo " ๐Ÿ“ฅ Fetching labels from issue #${ISSUE_NUM}" >&2 + local gh_output + if ! gh_output=$(gh issue view "${ISSUE_NUM}" --repo "${GITHUB_REPOSITORY}" --json labels -q '.labels[].name' 2>/dev/null); then + echo " โš ๏ธ Could not fetch issue #${ISSUE_NUM}" >&2 + ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:|" + return + fi + + local labels + labels=$(echo "${gh_output}" | grep -x -E '(area|priority)/.*|help wanted|๐Ÿ”’ maintainer only' | tr '\n' ',' | sed 's/,$//' || echo "") + + # Save to flat cache + ISSUE_LABELS_CACHE_FLAT="${ISSUE_LABELS_CACHE_FLAT}${ISSUE_NUM}:${labels}|" + echo "${labels}" +} + +# Function to process a single PR with pre-fetched data +process_pr_optimized() { + local PR_NUMBER="${1}" + local IS_DRAFT="${2}" + local ISSUE_NUMBER="${3}" + local CURRENT_LABELS="${4}" # Comma-separated labels + + echo "๐Ÿ”„ Processing PR #${PR_NUMBER}" + + local LABELS_TO_ADD="" + local LABELS_TO_REMOVE="" + + if [[ -z "${ISSUE_NUMBER}" || "${ISSUE_NUMBER}" == "null" || "${ISSUE_NUMBER}" == "" ]]; then + if [[ "${IS_DRAFT}" == "true" ]]; then + echo " ๐Ÿ“ PR #${PR_NUMBER} is a draft and has no linked issue" + if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then + echo " โž– Removing status/need-issue label" + LABELS_TO_REMOVE="status/need-issue" + fi + else + echo " โš ๏ธ No linked issue found for PR #${PR_NUMBER}" + if [[ ",${CURRENT_LABELS}," != *",status/need-issue,"* ]]; then + echo " โž• Adding status/need-issue label" + LABELS_TO_ADD="status/need-issue" + fi + + if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then + PRS_NEEDING_COMMENT="${PR_NUMBER}" + else + PRS_NEEDING_COMMENT="${PRS_NEEDING_COMMENT},${PR_NUMBER}" + fi + fi + else + echo " ๐Ÿ”— Found linked issue #${ISSUE_NUMBER}" + + if [[ ",${CURRENT_LABELS}," == *",status/need-issue,"* ]]; then + echo " โž– Removing status/need-issue label" + LABELS_TO_REMOVE="status/need-issue" + fi + + local ISSUE_LABELS + ISSUE_LABELS=$(get_issue_labels "${ISSUE_NUMBER}") + + if [[ -n "${ISSUE_LABELS}" ]]; then + local IFS_OLD="${IFS}" + IFS=',' + for label in ${ISSUE_LABELS}; do + if [[ -n "${label}" ]] && [[ ",${CURRENT_LABELS}," != *",${label},"* ]]; then + if [[ -z "${LABELS_TO_ADD}" ]]; then + LABELS_TO_ADD="${label}" + else + LABELS_TO_ADD="${LABELS_TO_ADD},${label}" + fi + fi +done + IFS="${IFS_OLD}" + fi + + if [[ -z "${LABELS_TO_ADD}" && -z "${LABELS_TO_REMOVE}" ]]; then + echo " โœ… Labels already synchronized" + fi + fi + + if [[ -n "${LABELS_TO_ADD}" || -n "${LABELS_TO_REMOVE}" ]]; then + local EDIT_CMD=("gh" "pr" "edit" "${PR_NUMBER}" "--repo" "${GITHUB_REPOSITORY}") + if [[ -n "${LABELS_TO_ADD}" ]]; then + echo " โž• Syncing labels to add: ${LABELS_TO_ADD}" + EDIT_CMD+=("--add-label" "${LABELS_TO_ADD}") + fi + if [[ -n "${LABELS_TO_REMOVE}" ]]; then + echo " โž– Syncing labels to remove: ${LABELS_TO_REMOVE}" + EDIT_CMD+=("--remove-label" "${LABELS_TO_REMOVE}") + fi + + ("${EDIT_CMD[@]}" || true) + fi +} + +if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + echo "โ€ผ๏ธ Missing \$GITHUB_REPOSITORY - this must be run from GitHub Actions" + exit 1 +fi + +if [[ -z "${GITHUB_OUTPUT:-}" ]]; then + echo "โ€ผ๏ธ Missing \$GITHUB_OUTPUT - this must be run from GitHub Actions" + exit 1 +fi + +JQ_EXTRACT_FIELDS='{ + number: .number, + isDraft: .isDraft, + issue: (.closingIssuesReferences[0].number // (.body // "" | capture("(^|[^a-zA-Z0-9])#(?[0-9]+)([^a-zA-Z0-9]|$)")? | .num) // "null"), + labels: [.labels[].name] | join(",") +}' + +JQ_TSV_FORMAT='"\((.number | tostring))\t\(.isDraft)\t\((.issue // null) | tostring)\t\(.labels)"' + +if [[ -n "${PR_NUMBER:-}" ]]; then + echo "๐Ÿ”„ Processing single PR #${PR_NUMBER}" + PR_DATA=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --json number,closingIssuesReferences,isDraft,body,labels 2>/dev/null) || { + echo "โŒ Failed to fetch data for PR #${PR_NUMBER}" + exit 1 + } + + line=$(echo "${PR_DATA}" | jq -r "${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}") + IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}" + process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}" +else + echo "๐Ÿ“ฅ Getting all open pull requests..." + PR_DATA_ALL=$(gh pr list --repo "${GITHUB_REPOSITORY}" --state open --limit 1000 --json number,closingIssuesReferences,isDraft,body,labels 2>/dev/null) || { + echo "โŒ Failed to fetch PR list" + exit 1 + } + + PR_COUNT=$(echo "${PR_DATA_ALL}" | jq '. | length') + echo "๐Ÿ“Š Found ${PR_COUNT} open PRs to process" + + # Use a temporary file to avoid masking exit codes in process substitution + tmp_file=$(mktemp) + echo "${PR_DATA_ALL}" | jq -r ".[] | ${JQ_EXTRACT_FIELDS} | ${JQ_TSV_FORMAT}" > "${tmp_file}" + while read -r line; do + [[ -z "${line}" ]] && continue + IFS=$'\t' read -r pr_num is_draft issue_num current_labels <<< "${line}" + process_pr_optimized "${pr_num}" "${is_draft}" "${issue_num}" "${current_labels}" + done < "${tmp_file}" + rm -f "${tmp_file}" +fi + +if [[ -z "${PRS_NEEDING_COMMENT}" ]]; then + echo "prs_needing_comment=[]" >> "${GITHUB_OUTPUT}" +else + echo "prs_needing_comment=[${PRS_NEEDING_COMMENT}]" >> "${GITHUB_OUTPUT}" +fi + +echo "โœ… PR triage completed" diff --git a/.github/scripts/sync-issue-types.cjs b/.github/scripts/sync-issue-types.cjs new file mode 100644 index 0000000000000000000000000000000000000000..4e547d4a668394549592a526ea1249d0eb7a5201 --- /dev/null +++ b/.github/scripts/sync-issue-types.cjs @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const fs = require('node:fs'); + +module.exports = async ({ github, context, core }) => { + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + issues(first: 50, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + id + number + title + body + issueType { + name + } + labels(first: 20) { + nodes { + name + } + } + } + } + } + } + `; + + try { + const result = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const issues = result.repository.issues.nodes; + const issuesNeedingAnalysis = []; + let syncedCount = 0; + + for (const issue of issues) { + if (issue.issueType === null) { + const labelNames = issue.labels.nodes.map((l) => l.name); + const hasBug = labelNames.includes('kind/bug'); + const hasFeature = + labelNames.includes('kind/feature') || + labelNames.includes('kind/enhancement'); + + let issueTypeId = null; + if (hasBug) { + issueTypeId = 'IT_kwDOCaSVvs4BR7vP'; // Bug + } else if (hasFeature) { + issueTypeId = 'IT_kwDOCaSVvs4BR7vQ'; // Feature + } + + if (issueTypeId) { + await github.graphql( + ` + mutation($issueId: ID!, $issueTypeId: ID!) { + updateIssue(input: {id: $issueId, issueTypeId: $issueTypeId}) { + issue { + id + } + } + } + `, + { + issueId: issue.id, + issueTypeId: issueTypeId, + }, + ); + core.info(`Successfully synced Issue Type for #${issue.number}`); + syncedCount++; + } else { + // Needs analysis to determine kind/type + issuesNeedingAnalysis.push({ + number: issue.number, + title: issue.title, + body: issue.body, + }); + } + } + } + + // Write issues needing analysis to a file so the AI can process them + fs.writeFileSync( + 'no_type_issues.json', + JSON.stringify(issuesNeedingAnalysis), + ); + core.info(`Synced ${syncedCount} issues from labels.`); + core.info( + `Found ${issuesNeedingAnalysis.length} issues missing both type and kind label to be analyzed.`, + ); + } catch (error) { + core.setFailed(`Failed to sync issue types: ${error.message}`); + } +}; diff --git a/.github/scripts/sync-maintainer-labels.cjs b/.github/scripts/sync-maintainer-labels.cjs new file mode 100644 index 0000000000000000000000000000000000000000..1ee4a3618a722a1e1820de8c148e6a13a52b3984 --- /dev/null +++ b/.github/scripts/sync-maintainer-labels.cjs @@ -0,0 +1,389 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const { Octokit } = require('@octokit/rest'); + +/** + * Sync Maintainer Labels (Recursive with strict parent-child relationship detection) + * - Uses Native Sub-issues. + * - Uses Markdown Task Lists (- [ ] #123). + * - Filters for OPEN issues only. + * - Skips DUPLICATES. + * - Skips Pull Requests. + * - ONLY labels issues in the PUBLIC (gemini-cli) repo. + */ + +const REPO_OWNER = 'google-gemini'; +const PUBLIC_REPO = 'gemini-cli'; +const PRIVATE_REPO = 'maintainers-gemini-cli'; +const ALLOWED_REPOS = [PUBLIC_REPO, PRIVATE_REPO]; + +const ROOT_ISSUES = [ + { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15374 }, + { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15456 }, + { owner: REPO_OWNER, repo: PUBLIC_REPO, number: 15324 }, +]; + +const TARGET_LABEL = '๐Ÿ”’ maintainer only'; +const isDryRun = + process.argv.includes('--dry-run') || process.env.DRY_RUN === 'true'; + +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, +}); + +/** + * Extracts child issue references from markdown Task Lists ONLY. + * e.g. - [ ] #123 or - [x] google-gemini/gemini-cli#123 + */ +function extractTaskListLinks(text, contextOwner, contextRepo) { + if (!text) return []; + const childIssues = new Map(); + + const add = (owner, repo, number) => { + if (ALLOWED_REPOS.includes(repo)) { + const key = `${owner}/${repo}#${number}`; + childIssues.set(key, { owner, repo, number: parseInt(number, 10) }); + } + }; + + // 1. Full URLs in task lists + const urlRegex = + /-\s+\[[ x]\].*https:\/\/github\.com\/([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)\/issues\/(\d+)\b/g; + let match; + while ((match = urlRegex.exec(text)) !== null) { + add(match[1], match[2], match[3]); + } + + // 2. Cross-repo refs in task lists: owner/repo#123 + const crossRepoRegex = + /-\s+\[[ x]\].*([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)#(\d+)\b/g; + while ((match = crossRepoRegex.exec(text)) !== null) { + add(match[1], match[2], match[3]); + } + + // 3. Short refs in task lists: #123 + const shortRefRegex = /-\s+\[[ x]\].*#(\d+)\b/g; + while ((match = shortRefRegex.exec(text)) !== null) { + add(contextOwner, contextRepo, match[1]); + } + + return Array.from(childIssues.values()); +} + +/** + * Fetches issue data via GraphQL with full pagination for sub-issues, comments, and labels. + */ +async function fetchIssueData(owner, repo, number) { + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + issue(number:$number) { + state + title + body + labels(first: 100) { + nodes { name } + pageInfo { hasNextPage endCursor } + } + subIssues(first: 100) { + nodes { + number + repository { + name + owner { login } + } + } + pageInfo { hasNextPage endCursor } + } + comments(first: 100) { + nodes { + body + } + } + } + } + } + `; + + try { + const response = await octokit.graphql(query, { owner, repo, number }); + const data = response.repository.issue; + if (!data) return null; + + const issue = { + state: data.state, + title: data.title, + body: data.body || '', + labels: data.labels.nodes.map((n) => n.name), + subIssues: [...data.subIssues.nodes], + comments: data.comments.nodes.map((n) => n.body), + }; + + // Paginate subIssues if there are more than 100 + if (data.subIssues.pageInfo.hasNextPage) { + const moreSubIssues = await paginateConnection( + owner, + repo, + number, + 'subIssues', + 'number repository { name owner { login } }', + data.subIssues.pageInfo.endCursor, + ); + issue.subIssues.push(...moreSubIssues); + } + + // Paginate labels if there are more than 100 (unlikely but for completeness) + if (data.labels.pageInfo.hasNextPage) { + const moreLabels = await paginateConnection( + owner, + repo, + number, + 'labels', + 'name', + data.labels.pageInfo.endCursor, + (n) => n.name, + ); + issue.labels.push(...moreLabels); + } + + // Note: Comments are handled via Task Lists in body + first 100 comments. + // If an issue has > 100 comments with task lists, we'd need to paginate those too. + // Given the 1,100+ issue discovery count, 100 comments is usually sufficient, + // but we can add it for absolute completeness. + // (Skipping for now to avoid excessive API churn unless clearly needed). + + return issue; + } catch (error) { + if (error.errors && error.errors.some((e) => e.type === 'NOT_FOUND')) { + return null; + } + throw error; + } +} + +/** + * Helper to paginate any GraphQL connection. + */ +async function paginateConnection( + owner, + repo, + number, + connectionName, + nodeFields, + initialCursor, + transformNode = (n) => n, +) { + let additionalNodes = []; + let hasNext = true; + let cursor = initialCursor; + + while (hasNext) { + const query = ` + query($owner:String!, $repo:String!, $number:Int!, $cursor:String) { + repository(owner:$owner, name:$repo) { + issue(number:$number) { + ${connectionName}(first: 100, after: $cursor) { + nodes { ${nodeFields} } + pageInfo { hasNextPage endCursor } + } + } + } + } + `; + const response = await octokit.graphql(query, { + owner, + repo, + number, + cursor, + }); + const connection = response.repository.issue[connectionName]; + additionalNodes.push(...connection.nodes.map(transformNode)); + hasNext = connection.pageInfo.hasNextPage; + cursor = connection.pageInfo.endCursor; + } + return additionalNodes; +} + +/** + * Validates if an issue should be processed (Open, not a duplicate, not a PR) + */ +function shouldProcess(issueData) { + if (!issueData) return false; + + if (issueData.state !== 'OPEN') return false; + + const labels = issueData.labels.map((l) => l.toLowerCase()); + if (labels.includes('duplicate') || labels.includes('kind/duplicate')) { + return false; + } + + return true; +} + +async function getAllDescendants(roots) { + const allDescendants = new Map(); + const visited = new Set(); + const queue = [...roots]; + + for (const root of roots) { + visited.add(`${root.owner}/${root.repo}#${root.number}`); + } + + console.log(`Starting discovery from ${roots.length} roots...`); + + while (queue.length > 0) { + const current = queue.shift(); + const currentKey = `${current.owner}/${current.repo}#${current.number}`; + + try { + const issueData = await fetchIssueData( + current.owner, + current.repo, + current.number, + ); + + if (!shouldProcess(issueData)) { + continue; + } + + // ONLY add to labeling list if it's in the PUBLIC repository + if (current.repo === PUBLIC_REPO) { + // Don't label the roots themselves + if ( + !ROOT_ISSUES.some( + (r) => r.number === current.number && r.repo === current.repo, + ) + ) { + allDescendants.set(currentKey, { + ...current, + title: issueData.title, + labels: issueData.labels, + }); + } + } + + const children = new Map(); + + // 1. Process Native Sub-issues + if (issueData.subIssues) { + for (const node of issueData.subIssues) { + const childOwner = node.repository.owner.login; + const childRepo = node.repository.name; + const childNumber = node.number; + const key = `${childOwner}/${childRepo}#${childNumber}`; + children.set(key, { + owner: childOwner, + repo: childRepo, + number: childNumber, + }); + } + } + + // 2. Process Markdown Task Lists in Body and Comments + let combinedText = issueData.body || ''; + if (issueData.comments) { + for (const commentBody of issueData.comments) { + combinedText += '\n' + (commentBody || ''); + } + } + + const taskListLinks = extractTaskListLinks( + combinedText, + current.owner, + current.repo, + ); + for (const link of taskListLinks) { + const key = `${link.owner}/${link.repo}#${link.number}`; + children.set(key, link); + } + + // Queue children (regardless of which repo they are in, for recursion) + for (const [key, child] of children) { + if (!visited.has(key)) { + visited.add(key); + queue.push(child); + } + } + } catch (error) { + console.error(`Error processing ${currentKey}: ${error.message}`); + } + } + + return Array.from(allDescendants.values()); +} + +async function run() { + if (isDryRun) { + console.log('=== DRY RUN MODE: No labels will be applied ==='); + } + + const descendants = await getAllDescendants(ROOT_ISSUES); + console.log( + `\nFound ${descendants.length} total unique open descendant issues in ${PUBLIC_REPO}.`, + ); + + for (const issueInfo of descendants) { + const issueKey = `${issueInfo.owner}/${issueInfo.repo}#${issueInfo.number}`; + try { + // Data is already available from the discovery phase + const hasLabel = issueInfo.labels.some((l) => l === TARGET_LABEL); + + if (!hasLabel) { + if (isDryRun) { + console.log( + `[DRY RUN] Would label ${issueKey}: "${issueInfo.title}"`, + ); + } else { + console.log(`Labeling ${issueKey}: "${issueInfo.title}"...`); + await octokit.rest.issues.addLabels({ + owner: issueInfo.owner, + repo: issueInfo.repo, + issue_number: issueInfo.number, + labels: [TARGET_LABEL], + }); + } + } + + // Remove status/need-triage from maintainer-only issues since they + // don't need community triage. We always attempt removal rather than + // checking the (potentially stale) label snapshot, because the + // issue-opened-labeler workflow runs concurrently and may add the + // label after our snapshot was taken. + if (isDryRun) { + console.log( + `[DRY RUN] Would remove status/need-triage from ${issueKey}`, + ); + } else { + try { + await octokit.rest.issues.removeLabel({ + owner: issueInfo.owner, + repo: issueInfo.repo, + issue_number: issueInfo.number, + name: 'status/need-triage', + }); + console.log(`Removed status/need-triage from ${issueKey}`); + } catch (removeError) { + // 404 means the label wasn't present โ€” that's fine. + if (removeError.status === 404) { + console.log( + `status/need-triage not present on ${issueKey}, skipping.`, + ); + } else { + throw removeError; + } + } + } + } catch (error) { + console.error(`Error processing label for ${issueKey}: ${error.message}`); + } + } +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/.github/workflows/agent-session-drift-check.yml b/.github/workflows/agent-session-drift-check.yml new file mode 100644 index 0000000000000000000000000000000000000000..3601f5ab091c3527d829a1320a02586af1878a15 --- /dev/null +++ b/.github/workflows/agent-session-drift-check.yml @@ -0,0 +1,132 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: 'Agent Session Drift Check' + +on: + pull_request: + branches: + - 'main' + - 'release/**' + paths: + - 'packages/cli/src/nonInteractiveCli.ts' + - 'packages/cli/src/nonInteractiveCliAgentSession.ts' + +concurrency: + group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + check-drift: + name: 'Check Agent Session Drift' + runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" + permissions: + contents: 'read' + pull-requests: 'write' + steps: + - name: 'Detect drift and comment' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # ratchet:actions/github-script@v8 + with: + script: |- + // === Pair configuration โ€” append here to cover more pairs === + const PAIRS = [ + { + legacy: 'packages/cli/src/nonInteractiveCli.ts', + session: 'packages/cli/src/nonInteractiveCliAgentSession.ts', + label: 'non-interactive CLI', + }, + // Future pairs can be added here. Remember to also add both + // paths to the `paths:` filter at the top of this workflow. + // Example: + // { + // legacy: 'packages/core/src/agents/local-invocation.ts', + // session: 'packages/core/src/agents/local-session-invocation.ts', + // label: 'local subagent invocation', + // }, + ]; + // ============================================================ + + const prNumber = context.payload.pull_request.number; + const { owner, repo } = context.repo; + + // Use the API to list changed files โ€” no checkout/git diff needed. + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + const changed = new Set(files.map((f) => f.filename)); + + const warnings = []; + for (const { legacy, session, label } of PAIRS) { + const legacyChanged = changed.has(legacy); + const sessionChanged = changed.has(session); + if (legacyChanged && !sessionChanged) { + warnings.push( + `**${label}**: \`${legacy}\` was modified but \`${session}\` was not.`, + ); + } else if (!legacyChanged && sessionChanged) { + warnings.push( + `**${label}**: \`${session}\` was modified but \`${legacy}\` was not.`, + ); + } + } + + const MARKER = ''; + + // Look up our existing drift comment (for upsert/cleanup). + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = comments.find( + (c) => c.user?.type === 'Bot' && c.body?.includes(MARKER), + ); + + if (warnings.length === 0) { + core.info('No drift detected.'); + // If drift was previously flagged and is now resolved, remove the comment. + if (existing) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existing.id, + }); + core.info(`Deleted stale drift comment ${existing.id}.`); + } + return; + } + + const body = [ + MARKER, + '### โš ๏ธ Invocation Drift Warning', + '', + 'The following file pairs should generally be kept in sync during the AgentSession migration:', + '', + ...warnings.map((w) => `- ${w}`), + '', + 'If this is intentional (e.g., a bug fix specific to one implementation), you can ignore this comment.', + '', + '_This check will be removed once the legacy implementations are deleted._', + ].join('\n'); + + if (existing) { + core.info(`Updating existing drift comment ${existing.id}.`); + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + } else { + core.info('Creating new drift comment.'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } diff --git a/.github/workflows/build-unsigned-mac-binaries.yml b/.github/workflows/build-unsigned-mac-binaries.yml new file mode 100644 index 0000000000000000000000000000000000000000..2acd67585ee2c216b56e09e30c0f042a9be212ea --- /dev/null +++ b/.github/workflows/build-unsigned-mac-binaries.yml @@ -0,0 +1,65 @@ +name: 'Build Unsigned Mac Binaries' + +on: + workflow_dispatch: + workflow_call: + inputs: + ref: + description: 'The branch, tag, or SHA to build from.' + required: true + type: 'string' + +permissions: + contents: 'read' + +defaults: + run: + shell: 'bash' + +jobs: + build-mac: + name: 'Build Unsigned (${{ matrix.arch }})' + runs-on: 'macos-latest' + strategy: + fail-fast: false + matrix: + arch: ['x64', 'arm64'] + + steps: + - name: 'Checkout' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 + with: + ref: '${{ inputs.ref || github.ref }}' + persist-credentials: false + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + architecture: '${{ matrix.arch }}' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build Binary' + env: + SKIP_SIGNING: 'true' + run: 'npm run build:binary' + + - name: 'Verify Output Exists' + run: | + if [ -f "dist/darwin-${{ matrix.arch }}/gemini" ]; then + echo "Binary found at dist/darwin-${{ matrix.arch }}/gemini" + else + echo "Error: Binary not found in dist/darwin-${{ matrix.arch }}/" + ls -R dist/ + exit 1 + fi + + - name: 'Upload Artifact' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'gemini-darwin-${{ matrix.arch }}-unsigned' + path: 'dist/darwin-${{ matrix.arch }}/gemini' + retention-days: 14 diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml new file mode 100644 index 0000000000000000000000000000000000000000..a807fbfb37d34fe52c31e5df0de248640ff23af9 --- /dev/null +++ b/.github/workflows/chained_e2e.yml @@ -0,0 +1,406 @@ +name: 'Testing: E2E (Chained)' + +on: + push: + branches: + - 'main' + merge_group: + workflow_run: + workflows: ['Trigger E2E'] + types: ['completed'] + workflow_dispatch: + inputs: + head_sha: + description: 'SHA of the commit to test' + required: true + repo_name: + description: 'Repository name (e.g., owner/repo)' + required: true + +concurrency: + group: '${{ github.workflow }}-${{ github.head_ref || github.event.workflow_run.head_branch || github.ref }}' + cancel-in-progress: |- + ${{ github.event_name != 'push' && github.event_name != 'merge_group' }} + +permissions: + contents: 'read' + statuses: 'write' + +jobs: + merge_queue_skipper: + name: 'Merge Queue Skipper' + permissions: 'read-all' + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" + outputs: + skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}' + steps: + - id: 'merge-queue-e2e-skipper' + uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main + with: + secret: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + continue-on-error: true + + download_repo_name: + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')" + outputs: + repo_name: '${{ steps.output-repo-name.outputs.repo_name }}' + head_sha: '${{ steps.output-repo-name.outputs.head_sha }}' + steps: + - name: 'Mock Repo Artifact' + if: "${{ github.event_name == 'workflow_dispatch' }}" + env: + REPO_NAME: '${{ github.event.inputs.repo_name }}' + run: | + mkdir -p ./pr + echo "${REPO_NAME}" > ./pr/repo_name + - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'repo_name' + path: 'pr/' + - name: 'Download the repo_name artifact' + uses: 'actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0' # ratchet:actions/download-artifact@v5 + env: + RUN_ID: "${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || github.run_id }}" + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + name: 'repo_name' + run-id: '${{ env.RUN_ID }}' + path: '${{ runner.temp }}/artifacts' + - name: 'Output Repo Name and SHA' + id: 'output-repo-name' + uses: 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' # ratchet:actions/github-script@v8 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + const fs = require('fs'); + const path = require('path'); + const temp = '${{ runner.temp }}/artifacts'; + const repoPath = path.join(temp, 'repo_name'); + if (fs.existsSync(repoPath)) { + const repo_name = String(fs.readFileSync(repoPath)).trim(); + core.setOutput('repo_name', repo_name); + } + const shaPath = path.join(temp, 'head_sha'); + if (fs.existsSync(shaPath)) { + const head_sha = String(fs.readFileSync(shaPath)).trim(); + core.setOutput('head_sha', head_sha); + } + + parse_run_context: + name: 'Parse run context' + runs-on: 'gemini-cli-ubuntu-16-core' + needs: 'download_repo_name' + if: "github.repository == 'google-gemini/gemini-cli' && always()" + outputs: + repository: '${{ steps.set_context.outputs.REPO }}' + sha: '${{ steps.set_context.outputs.SHA }}' + steps: + - id: 'set_context' + name: 'Set dynamic repository and SHA' + env: + REPO: '${{ needs.download_repo_name.outputs.repo_name || github.repository }}' + SHA: '${{ needs.download_repo_name.outputs.head_sha || github.event.inputs.head_sha || github.event.workflow_run.head_sha || github.sha }}' + shell: 'bash' + run: | + echo "REPO=$REPO" >> "$GITHUB_OUTPUT" + echo "SHA=$SHA" >> "$GITHUB_OUTPUT" + + set_pending_status: + runs-on: 'gemini-cli-ubuntu-16-core' + permissions: 'write-all' + needs: + - 'parse_run_context' + if: "github.repository == 'google-gemini/gemini-cli' && always()" + steps: + - name: 'Set pending status' + uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master + if: "github.repository == 'google-gemini/gemini-cli' && always()" + with: + allowForks: 'true' + repo: '${{ github.repository }}' + sha: '${{ needs.parse_run_context.outputs.sha }}' + token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + status: 'pending' + context: 'E2E (Chained)' + + e2e_linux: + name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' + needs: + - 'merge_queue_skipper' + - 'parse_run_context' + runs-on: 'gemini-cli-ubuntu-16-core' + if: | + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + strategy: + fail-fast: false + matrix: + sandbox: + - 'sandbox:none' + - 'sandbox:docker' + node-version: + - '20.x' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ needs.parse_run_context.outputs.sha }}' + repository: '${{ needs.parse_run_context.outputs.repository }}' + persist-credentials: false + + - name: 'Set up Node.js ${{ matrix.node-version }}' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '${{ matrix.node-version }}' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Set up Docker' + if: "${{matrix.sandbox == 'sandbox:docker'}}" + uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3 + + - name: 'Run E2E tests' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + KEEP_OUTPUT: 'true' + VERBOSE: 'true' + BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max' + shell: 'bash' + run: | + if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then + npm run test:integration:sandbox:docker + else + npm run test:integration:sandbox:none + fi + + e2e_mac: + name: 'E2E Test (macOS)' + needs: + - 'merge_queue_skipper' + - 'parse_run_context' + runs-on: 'macos-latest-large' + if: | + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ needs.parse_run_context.outputs.sha }}' + repository: '${{ needs.parse_run_context.outputs.repository }}' + persist-credentials: false + + - name: 'Set up Node.js 20.x' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '20.x' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Fix rollup optional dependencies on macOS' + if: "${{runner.os == 'macOS'}}" + run: | + npm cache clean --force + - name: 'Run E2E tests (non-Windows)' + if: "${{runner.os != 'Windows'}}" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + KEEP_OUTPUT: 'true' + SANDBOX: 'sandbox:none' + VERBOSE: 'true' + run: 'npm run test:integration:sandbox:none' + + e2e_windows: + name: 'Slow E2E - Win' + needs: + - 'merge_queue_skipper' + - 'parse_run_context' + if: | + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + runs-on: 'gemini-cli-windows-16-core' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ needs.parse_run_context.outputs.sha }}' + repository: '${{ needs.parse_run_context.outputs.repository }}' + persist-credentials: false + + - name: 'Set up Node.js 20.x' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: 'Configure Windows Defender exclusions' + run: | + Add-MpPreference -ExclusionPath $env:GITHUB_WORKSPACE -Force + Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\node_modules" -Force + Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\packages" -Force + Add-MpPreference -ExclusionPath "$env:TEMP" -Force + shell: 'pwsh' + + - name: 'Configure npm for Windows performance' + run: | + npm config set progress false + npm config set audit false + npm config set fund false + npm config set loglevel error + npm config set maxsockets 32 + npm config set registry https://registry.npmjs.org/ + shell: 'pwsh' + + - name: 'Install dependencies' + run: 'npm ci' + shell: 'pwsh' + + - name: 'Build project' + run: 'npm run build' + shell: 'pwsh' + + - name: 'Ensure Chrome is available' + shell: 'pwsh' + run: | + $chromePaths = @( + "${env:ProgramFiles}\Google\Chrome\Application\chrome.exe", + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe" + ) + $chromeExists = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $chromeExists) { + Write-Host 'Chrome not found, installing via Chocolatey...' + choco install googlechrome -y --no-progress --ignore-checksums + } + $installed = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if ($installed) { + Write-Host "Chrome found at: $installed" + & $installed --version + } else { + Write-Error 'Chrome installation failed' + exit 1 + } + + - name: 'Run E2E tests' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + KEEP_OUTPUT: 'true' + SANDBOX: 'sandbox:none' + VERBOSE: 'true' + NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' + UV_THREADPOOL_SIZE: '32' + NODE_ENV: 'test' + shell: 'pwsh' + run: 'npm run test:integration:sandbox:none' + + evals: + name: 'Evals (ALWAYS_PASSING)' + needs: + - 'merge_queue_skipper' + - 'parse_run_context' + runs-on: 'gemini-cli-ubuntu-16-core' + if: | + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ needs.parse_run_context.outputs.sha }}' + repository: '${{ needs.parse_run_context.outputs.repository }}' + persist-credentials: false + fetch-depth: 0 + + - name: 'Set up Node.js 20.x' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '20.x' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Check if evals should run' + id: 'check_evals' + run: | + SHOULD_RUN=$(node scripts/changed_prompt.js) + echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + + - name: 'Run Evals (Required to pass)' + if: "${{ steps.check_evals.outputs.should_run == 'true' }}" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + GEMINI_MODEL: 'gemini-3-pro-preview' + # Only run always passes behavioral tests. + EVAL_SUITE_TYPE: 'behavioral' + # Disable Vitest internal retries to avoid double-retrying; + # custom retry logic is handled in evals/test-helper.ts + VITEST_RETRY: 0 + run: 'npm run test:always_passing_evals' + + - name: 'Upload Reliability Logs' + if: "always() && steps.check_evals.outputs.should_run == 'true'" + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'eval-logs-${{ github.run_id }}-${{ github.run_attempt }}' + path: 'evals/logs/api-reliability.jsonl' + retention-days: 7 + + e2e: + name: 'E2E' + if: | + github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true') + needs: + - 'e2e_linux' + - 'e2e_mac' + - 'e2e_windows' + - 'evals' + - 'merge_queue_skipper' + runs-on: 'gemini-cli-ubuntu-16-core' + steps: + - name: 'Check E2E test results' + run: | + if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \ + ${NEEDS_E2E_MAC_RESULT} != 'success' || \ + ${NEEDS_E2E_WINDOWS_RESULT} != 'success' || \ + ${NEEDS_EVALS_RESULT} != 'success' ]]; then + echo "One or more E2E jobs failed." + exit 1 + fi + echo "All required E2E jobs passed!" + env: + NEEDS_E2E_LINUX_RESULT: '${{ needs.e2e_linux.result }}' + NEEDS_E2E_MAC_RESULT: '${{ needs.e2e_mac.result }}' + NEEDS_E2E_WINDOWS_RESULT: '${{ needs.e2e_windows.result }}' + NEEDS_EVALS_RESULT: '${{ needs.evals.result }}' + + set_workflow_status: + runs-on: 'gemini-cli-ubuntu-16-core' + permissions: 'write-all' + if: "github.repository == 'google-gemini/gemini-cli' && always()" + needs: + - 'parse_run_context' + - 'e2e' + steps: + - name: 'Set workflow status' + uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master + if: "github.repository == 'google-gemini/gemini-cli' && always()" + with: + allowForks: 'true' + repo: '${{ github.repository }}' + sha: '${{ needs.parse_run_context.outputs.sha }}' + token: '${{ secrets.GITHUB_TOKEN }}' + status: '${{ needs.e2e.result }}' + context: 'E2E (Chained)' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..5da8e6e05af0c77d1437759069e6be8e11d65044 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,518 @@ +name: 'Testing: CI' + +on: + push: + branches: + - 'main' + - 'release/**' + pull_request: + branches: + - 'main' + - 'release/**' + merge_group: + workflow_dispatch: + inputs: + branch_ref: + description: 'Branch to run on' + required: true + default: 'main' + type: 'string' + +concurrency: + group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + cancel-in-progress: |- + ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} + +permissions: + checks: 'write' + contents: 'read' + statuses: 'write' + +defaults: + run: + shell: 'bash' + +jobs: + merge_queue_skipper: + permissions: 'read-all' + name: 'Merge Queue Skipper' + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" + outputs: + skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}' + steps: + - id: 'merge-queue-ci-skipper' + uses: 'cariad-tech/merge-queue-ci-skipper@1032489e59437862c90a08a2c92809c903883772' # ratchet:cariad-tech/merge-queue-ci-skipper@main + with: + secret: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + + lint: + name: 'Lint' + runs-on: 'gemini-cli-ubuntu-16-core' + needs: 'merge_queue_skipper' + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" + env: + GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + ref: '${{ github.event.inputs.branch_ref || github.ref }}' + fetch-depth: 0 + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Cache Linters' + uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # ratchet:actions/cache@v4 + with: + path: '${{ env.GEMINI_LINT_TEMP_DIR }}' + key: "${{ runner.os }}-${{ runner.arch }}-linters-${{ hashFiles('scripts/lint.js') }}" + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Cache ESLint' + uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # ratchet:actions/cache@v4 + with: + path: '.eslintcache' + key: "${{ runner.os }}-eslint-${{ hashFiles('package-lock.json', 'eslint.config.js') }}" + + - name: 'Validate NOTICES.txt' + run: 'git diff --exit-code packages/vscode-ide-companion/NOTICES.txt' + + - name: 'Check lockfile' + run: 'npm run check:lockfile' + + - name: 'Install linters' + run: 'node scripts/lint.js --setup' + + - name: 'Run ESLint' + run: 'node scripts/lint.js --eslint' + + - name: 'Run actionlint' + run: 'node scripts/lint.js --actionlint' + + - name: 'Run shellcheck' + run: 'node scripts/lint.js --shellcheck' + + - name: 'Run yamllint' + run: 'node scripts/lint.js --yamllint' + + - name: 'Build project for typecheck' + run: 'npm run build' + + - name: 'Run typecheck' + run: 'npm run typecheck' + + - name: 'Run Prettier' + run: 'node scripts/lint.js --prettier' + + - name: 'Build docs prerequisites' + run: 'npm run predocs:settings' + + - name: 'Verify settings docs' + run: 'npm run docs:settings -- --check' + + - name: 'Run sensitive keyword linter' + run: 'node scripts/lint.js --sensitive-keywords' + + - name: 'Run GitHub Actions pinning linter' + run: 'node scripts/lint.js --check-github-actions-pinning' + + link_checker: + name: 'Link Checker' + runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + - name: 'Link Checker' + uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1 + with: + args: '--verbose --accept 200,503 ./**/*.md' + fail: true + test_linux: + name: 'Test (Linux) - ${{ matrix.node-version }}, ${{ matrix.shard }}' + runs-on: 'gemini-cli-ubuntu-16-core' + needs: + - 'merge_queue_skipper' + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" + permissions: + contents: 'read' + checks: 'write' + pull-requests: 'write' + strategy: + matrix: + node-version: + - '20.x' + - '22.x' + - '24.x' + shard: + - 'cli' + - 'others' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Set up Node.js ${{ matrix.node-version }}' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '${{ matrix.node-version }}' + cache: 'npm' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Install system dependencies' + run: | + sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bubblewrap + # Ubuntu 24.04+ requires this to allow bwrap to function in CI + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + + - name: 'Install dependencies for testing' + run: 'npm ci' + + - name: 'Run tests and generate reports' + env: + NO_COLOR: true + GEMINI_CLI_TRUST_WORKSPACE: true + run: | + if [[ "${{ matrix.shard }}" == "cli" ]]; then + npm run test:ci --workspace "@google/gemini-cli" + else + # Explicitly list non-cli packages to ensure they are sharded correctly + npm run test:ci --workspace "@google/gemini-cli-core" --workspace "@google/gemini-cli-a2a-server" --workspace "gemini-cli-vscode-ide-companion" --workspace "@google/gemini-cli-test-utils" --if-present -- --coverage.enabled=false + npm run test:scripts + fi + + - name: 'Bundle' + run: 'npm run bundle' + + - name: 'Smoke test bundle' + run: 'node ./bundle/gemini.js --version' + + - name: 'Smoke test npx installation' + run: | + # 1. Package the project into a tarball + TARBALL=$(npm pack | tail -n 1) + + # 2. Move to a fresh directory for isolation + mkdir -p ../smoke-test-dir + mv "$TARBALL" ../smoke-test-dir/ + cd ../smoke-test-dir + + # 3. Run npx from the tarball + npx "./$TARBALL" --version + + - name: 'Wait for file system sync' + run: 'sleep 2' + + - name: 'Publish Test Report (for non-forks)' + if: |- + ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} + uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 + with: + name: 'Test Results (Node ${{ runner.os }}, ${{ matrix.node-version }}, ${{ matrix.shard }})' + path: 'packages/*/junit.xml' + reporter: 'java-junit' + fail-on-error: 'false' + + - name: 'Upload Test Results Artifact (for forks)' + if: |- + ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'test-results-fork-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' + path: 'packages/*/junit.xml' + + test_mac: + name: 'Test (Mac) - ${{ matrix.node-version }}, ${{ matrix.shard }}' + runs-on: 'macos-latest-large' + needs: + - 'merge_queue_skipper' + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" + permissions: + contents: 'read' + checks: 'write' + pull-requests: 'write' + continue-on-error: true + strategy: + matrix: + node-version: + - '20.x' + - '22.x' + - '24.x' + shard: + - 'cli' + - 'others' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Set up Node.js ${{ matrix.node-version }}' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '${{ matrix.node-version }}' + cache: 'npm' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Install dependencies for testing' + run: 'npm ci' + + - name: 'Run tests and generate reports' + env: + NO_COLOR: true + GEMINI_CLI_TRUST_WORKSPACE: true + run: | + if [[ "${{ matrix.shard }}" == "cli" ]]; then + npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false + else + # Explicitly list non-cli packages to ensure they are sharded correctly + npm run test:ci --workspace "@google/gemini-cli-core" --workspace "@google/gemini-cli-a2a-server" --workspace "gemini-cli-vscode-ide-companion" --workspace "@google/gemini-cli-test-utils" --if-present -- --coverage.enabled=false + npm run test:scripts + fi + + - name: 'Bundle' + run: 'npm run bundle' + + - name: 'Smoke test bundle' + run: 'node ./bundle/gemini.js --version' + + - name: 'Smoke test npx installation' + run: | + # 1. Package the project into a tarball + TARBALL=$(npm pack | tail -n 1) + + # 2. Move to a fresh directory for isolation + mkdir -p ../smoke-test-dir + mv "$TARBALL" ../smoke-test-dir/ + cd ../smoke-test-dir + + # 3. Run npx from the tarball + npx "./$TARBALL" --version + + - name: 'Wait for file system sync' + run: 'sleep 2' + + - name: 'Publish Test Report (for non-forks)' + if: |- + ${{ always() && (github.event.pull_request.head.repo.full_name == github.repository) }} + uses: 'dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3' # ratchet:dorny/test-reporter@v2 + with: + name: 'Test Results (Node ${{ runner.os }}, ${{ matrix.node-version }}, ${{ matrix.shard }})' + path: 'packages/*/junit.xml' + reporter: 'java-junit' + fail-on-error: 'false' + + - name: 'Upload Test Results Artifact (for forks)' + if: |- + ${{ always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }} + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'test-results-fork-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' + path: 'packages/*/junit.xml' + + - name: 'Upload coverage reports' + if: |- + ${{ always() }} + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'coverage-reports-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.shard }}' + path: 'packages/*/coverage' + + codeql: + name: 'CodeQL' + runs-on: 'gemini-cli-ubuntu-16-core' + needs: 'merge_queue_skipper' + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" + permissions: + actions: 'read' + contents: 'read' + security-events: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + ref: '${{ github.event.inputs.branch_ref || github.ref }}' + + - name: 'Initialize CodeQL' + uses: 'github/codeql-action/init@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/init@v3 + with: + languages: 'javascript' + + - name: 'Perform CodeQL Analysis' + uses: 'github/codeql-action/analyze@df559355d593797519d70b90fc8edd5db049e7a2' # ratchet:github/codeql-action/analyze@v3 + + # Check for changes in bundle size. + bundle_size: + name: 'Check Bundle Size' + needs: 'merge_queue_skipper' + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'" + runs-on: 'gemini-cli-ubuntu-16-core' + permissions: + contents: 'read' # For checkout + pull-requests: 'write' # For commenting + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + ref: '${{ github.event.inputs.branch_ref || github.ref }}' + fetch-depth: 1 + + - uses: 'preactjs/compressed-size-action@946a292cd35bd1088e0d7eb92b69d1a8d5b5d76a' + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + pattern: './bundle/**/*.{js,sb}' + minimum-change-threshold: '1000' + compression: 'none' + clean-script: 'clean' + + test_windows: + name: 'Slow Test - Win - ${{ matrix.shard }}' + runs-on: 'gemini-cli-windows-16-core' + needs: 'merge_queue_skipper' + if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'" + timeout-minutes: 60 + strategy: + matrix: + shard: + - 'cli' + - 'others' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + ref: '${{ github.event.inputs.branch_ref || github.ref }}' + + - name: 'Set up Node.js 20.x' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: 'Configure Windows Defender exclusions' + run: | + Add-MpPreference -ExclusionPath $env:GITHUB_WORKSPACE -Force + Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\node_modules" -Force + Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\packages" -Force + Add-MpPreference -ExclusionPath "$env:TEMP" -Force + shell: 'pwsh' + + - name: 'Configure npm for Windows performance' + run: | + npm config set progress false + npm config set audit false + npm config set fund false + npm config set loglevel error + npm config set maxsockets 32 + npm config set registry https://registry.npmjs.org/ + shell: 'pwsh' + + - name: 'Install dependencies' + run: 'npm ci' + shell: 'pwsh' + + - name: 'Build project' + run: 'npm run build' + shell: 'pwsh' + env: + NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' + UV_THREADPOOL_SIZE: '32' + NODE_ENV: 'production' + + - name: 'Run tests and generate reports' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + NO_COLOR: true + GEMINI_CLI_TRUST_WORKSPACE: true + NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' + UV_THREADPOOL_SIZE: '32' + NODE_ENV: 'test' + run: | + if ("${{ matrix.shard }}" -eq "cli") { + npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } else { + # Explicitly list non-cli packages to ensure they are sharded correctly + npm run test:ci --workspace "@google/gemini-cli-core" --workspace "@google/gemini-cli-a2a-server" --workspace "gemini-cli-vscode-ide-companion" --workspace "@google/gemini-cli-test-utils" --if-present -- --coverage.enabled=false + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + npm run test:scripts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + shell: 'pwsh' + + - name: 'Bundle' + run: 'npm run bundle' + shell: 'pwsh' + + - name: 'Smoke test bundle' + run: 'node ./bundle/gemini.js --version' + shell: 'pwsh' + + - name: 'Smoke test npx installation' + run: | + # 1. Package the project into a tarball + $PACK_OUTPUT = npm pack + $TARBALL = $PACK_OUTPUT[-1] + + # 2. Move to a fresh directory for isolation + New-Item -ItemType Directory -Force -Path ../smoke-test-dir + Move-Item $TARBALL ../smoke-test-dir/ + Set-Location ../smoke-test-dir + + # 3. Run npx from the tarball + npx "./$TARBALL" --version + shell: 'pwsh' + + ci: + name: 'CI' + if: "github.repository == 'google-gemini/gemini-cli' && always()" + needs: + - 'lint' + - 'link_checker' + - 'test_linux' + - 'test_mac' + - 'test_windows' + - 'codeql' + - 'bundle_size' + runs-on: 'gemini-cli-ubuntu-16-core' + steps: + - name: 'Check all job results' + run: | + if [[ (${NEEDS_LINT_RESULT} != 'success' && ${NEEDS_LINT_RESULT} != 'skipped') || \ + (${NEEDS_LINK_CHECKER_RESULT} != 'success' && ${NEEDS_LINK_CHECKER_RESULT} != 'skipped') || \ + (${NEEDS_TEST_LINUX_RESULT} != 'success' && ${NEEDS_TEST_LINUX_RESULT} != 'skipped') || \ + (${NEEDS_TEST_MAC_RESULT} != 'success' && ${NEEDS_TEST_MAC_RESULT} != 'skipped') || \ + (${NEEDS_TEST_WINDOWS_RESULT} != 'success' && ${NEEDS_TEST_WINDOWS_RESULT} != 'skipped') || \ + (${NEEDS_CODEQL_RESULT} != 'success' && ${NEEDS_CODEQL_RESULT} != 'skipped') || \ + (${NEEDS_BUNDLE_SIZE_RESULT} != 'success' && ${NEEDS_BUNDLE_SIZE_RESULT} != 'skipped') ]]; then + echo "One or more CI jobs failed." + exit 1 + fi + echo "All CI jobs passed!" + env: + NEEDS_LINT_RESULT: '${{ needs.lint.result }}' + NEEDS_LINK_CHECKER_RESULT: '${{ needs.link_checker.result }}' + NEEDS_TEST_LINUX_RESULT: '${{ needs.test_linux.result }}' + NEEDS_TEST_MAC_RESULT: '${{ needs.test_mac.result }}' + NEEDS_TEST_WINDOWS_RESULT: '${{ needs.test_windows.result }}' + NEEDS_CODEQL_RESULT: '${{ needs.codeql.result }}' + NEEDS_BUNDLE_SIZE_RESULT: '${{ needs.bundle_size.result }}' diff --git a/.github/workflows/community-report.yml b/.github/workflows/community-report.yml new file mode 100644 index 0000000000000000000000000000000000000000..86ac45aa947e99a60e80a016f3d4079233f2df55 --- /dev/null +++ b/.github/workflows/community-report.yml @@ -0,0 +1,200 @@ +name: 'Generate Weekly Community Report ๐Ÿ“Š' + +on: + schedule: + - cron: '0 12 * * 1' # Run at 12:00 UTC on Monday + workflow_dispatch: + inputs: + days: + description: 'Number of days to look back for the report' + required: true + default: '7' + +jobs: + generate-report: + name: 'Generate Report ๐Ÿ“' + if: |- + ${{ github.repository == 'google-gemini/gemini-cli' }} + runs-on: 'ubuntu-latest' + permissions: + issues: 'write' + pull-requests: 'read' + discussions: 'read' + contents: 'read' + id-token: 'write' + + steps: + - name: 'Generate GitHub App Token ๐Ÿ”‘' + id: 'generate_token' + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + permission-issues: 'write' + permission-pull-requests: 'read' + permission-discussions: 'read' + permission-contents: 'read' + + - name: 'Generate Report ๐Ÿ“œ' + id: 'report' + env: + GH_TOKEN: '${{ steps.generate_token.outputs.token }}' + REPO: '${{ github.repository }}' + DAYS: '${{ github.event.inputs.days || 7 }}' + run: |- + set -e + + START_DATE="$(date -u -d "$DAYS days ago" +'%Y-%m-%d')" + END_DATE="$(date -u +'%Y-%m-%d')" + echo "โณ Generating report for contributions from ${START_DATE} to ${END_DATE}..." + + declare -A author_is_googler + check_googler_status() { + local author="$1" + if [[ "${author}" == *"[bot]" ]]; then + author_is_googler[${author}]=1 + return 1 + fi + if [[ -v "author_is_googler[${author}]" ]]; then + return "${author_is_googler[${author}]}" + fi + + if gh api "orgs/googlers/members/${author}" --silent 2>/dev/null; then + echo "๐Ÿง‘โ€๐Ÿ’ป ${author} is a Googler." + author_is_googler[${author}]=0 + else + echo "๐ŸŒ ${author} is a community contributor." + author_is_googler[${author}]=1 + fi + return "${author_is_googler[${author}]}" + } + + googler_issues=0 + non_googler_issues=0 + googler_prs=0 + non_googler_prs=0 + + echo "๐Ÿ”Ž Fetching issues and pull requests..." + ITEMS_JSON="$(gh search issues --repo "${REPO}" "created:>${START_DATE}" --json author,isPullRequest --limit 1000)" + + for row in $(echo "${ITEMS_JSON}" | jq -r '.[] | @base64'); do + _jq() { + echo "${row}" | base64 --decode | jq -r "${1}" + } + author="$(_jq '.author.login')" + is_pr="$(_jq '.isPullRequest')" + + if [[ -z "${author}" || "${author}" == "null" ]]; then + continue + fi + + if check_googler_status "${author}"; then + if [[ "${is_pr}" == "true" ]]; then + ((googler_prs++)) + else + ((googler_issues++)) + fi + else + if [[ "${is_pr}" == "true" ]]; then + ((non_googler_prs++)) + else + ((non_googler_issues++)) + fi + fi + done + + googler_discussions=0 + non_googler_discussions=0 + + echo "๐Ÿ—ฃ๏ธ Fetching discussions..." + DISCUSSION_QUERY=''' + query($q: String!) { + search(query: $q, type: DISCUSSION, first: 100) { + nodes { + ... on Discussion { + author { + login + } + } + } + } + }''' + DISCUSSIONS_JSON="$(gh api graphql -f q="repo:${REPO} created:>${START_DATE}" -f query="${DISCUSSION_QUERY}")" + + for row in $(echo "${DISCUSSIONS_JSON}" | jq -r '.data.search.nodes[] | @base64'); do + _jq() { + echo "${row}" | base64 --decode | jq -r "${1}" + } + author="$(_jq '.author.login')" + + if [[ -z "${author}" || "${author}" == "null" ]]; then + continue + fi + + if check_googler_status "${author}"; then + ((googler_discussions++)) + else + ((non_googler_discussions++)) + fi + done + + echo "โœ๏ธ Generating report content..." + TOTAL_ISSUES=$((googler_issues + non_googler_issues)) + TOTAL_PRS=$((googler_prs + non_googler_prs)) + TOTAL_DISCUSSIONS=$((googler_discussions + non_googler_discussions)) + + REPORT_BODY=$(cat <> "${GITHUB_OUTPUT}" + echo "${REPORT_BODY}" >> "${GITHUB_OUTPUT}" + echo "EOF" >> "${GITHUB_OUTPUT}" + + echo "๐Ÿ“Š Community Contribution Report:" + echo "${REPORT_BODY}" + + - name: '๐Ÿค– Get Insights from Report' + if: |- + ${{ steps.report.outputs.report_body != '' }} + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + env: + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + REPOSITORY: '${{ github.repository }}' + with: + upload_artifacts: 'true' + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- + { + "tools": { + "core": [ + "run_shell_command(gh issue list)", + "run_shell_command(gh pr list)", + "run_shell_command(gh search issues)", + "run_shell_command(gh search prs)" + ] + } + } + prompt: |- + You are a helpful assistant that analyzes community contribution reports. + Based on the following report, please provide a brief summary and highlight any interesting trends or potential areas for improvement. + + Report: + ${{ steps.report.outputs.report_body }} diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml new file mode 100644 index 0000000000000000000000000000000000000000..5d94dfc84e4bfee5e1f563b1aee894f87c05ada1 --- /dev/null +++ b/.github/workflows/deflake.yml @@ -0,0 +1,178 @@ +name: 'Deflake E2E' + +on: + workflow_dispatch: + inputs: + branch_ref: + description: 'Branch to run on' + required: true + default: 'main' + type: 'string' + test_name_pattern: + description: 'The test name pattern to use' + required: false + type: 'string' + runs: + description: 'The number of runs' + required: false + default: 5 + type: 'number' + +concurrency: + group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + cancel-in-progress: |- + ${{ github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') }} + +jobs: + deflake_e2e_linux: + name: 'E2E Test (Linux) - ${{ matrix.sandbox }}' + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" + strategy: + fail-fast: false + matrix: + sandbox: + - 'sandbox:none' + - 'sandbox:docker' + node-version: + - '20.x' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.pull_request.head.sha }}' + repository: '${{ github.repository }}' + persist-credentials: false + + - name: 'Set up Node.js ${{ matrix.node-version }}' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '${{ matrix.node-version }}' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Set up Docker' + if: "matrix.sandbox == 'sandbox:docker'" + uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3 + + - name: 'Run E2E tests' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}" + KEEP_OUTPUT: 'true' + RUNS: '${{ github.event.inputs.runs }}' + TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + VERBOSE: 'true' + shell: 'bash' + run: | + if [[ "${IS_DOCKER}" == "true" ]]; then + npm run deflake:test:integration:sandbox:docker -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" + else + npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" + fi + + deflake_e2e_mac: + name: 'E2E Test (macOS)' + runs-on: 'macos-latest-large' + if: "github.repository == 'google-gemini/gemini-cli'" + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.pull_request.head.sha }}' + repository: '${{ github.repository }}' + persist-credentials: false + + - name: 'Set up Node.js 20.x' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '20.x' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Fix rollup optional dependencies on macOS' + if: "runner.os == 'macOS'" + run: | + npm cache clean --force + - name: 'Run E2E tests (non-Windows)' + if: "runner.os != 'Windows'" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + KEEP_OUTPUT: 'true' + RUNS: '${{ github.event.inputs.runs }}' + SANDBOX: 'sandbox:none' + TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + VERBOSE: 'true' + run: | + npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" + + deflake_e2e_windows: + name: 'Slow E2E - Win' + runs-on: 'gemini-cli-windows-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.pull_request.head.sha }}' + repository: '${{ github.repository }}' + persist-credentials: false + + - name: 'Set up Node.js 20.x' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: 'Configure Windows Defender exclusions' + run: | + Add-MpPreference -ExclusionPath $env:GITHUB_WORKSPACE -Force + Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\node_modules" -Force + Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE\packages" -Force + Add-MpPreference -ExclusionPath "$env:TEMP" -Force + shell: 'pwsh' + + - name: 'Configure npm for Windows performance' + run: | + npm config set progress false + npm config set audit false + npm config set fund false + npm config set loglevel error + npm config set maxsockets 32 + npm config set registry https://registry.npmjs.org/ + shell: 'pwsh' + + - name: 'Install dependencies' + run: 'npm ci' + shell: 'pwsh' + + - name: 'Build project' + run: 'npm run build' + shell: 'pwsh' + + - name: 'Run E2E tests' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + KEEP_OUTPUT: 'true' + SANDBOX: 'sandbox:none' + VERBOSE: 'true' + NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256' + UV_THREADPOOL_SIZE: '32' + NODE_ENV: 'test' + RUNS: '${{ github.event.inputs.runs }}' + TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + shell: 'pwsh' + run: | + npm run deflake:test:integration:sandbox:none -- --runs="$env:RUNS" -- --testNamePattern "'$env:TEST_NAME_PATTERN'" diff --git a/.github/workflows/docs-audit.yml b/.github/workflows/docs-audit.yml new file mode 100644 index 0000000000000000000000000000000000000000..9ba28732784aaaf924d897d51c7177f118cc6450 --- /dev/null +++ b/.github/workflows/docs-audit.yml @@ -0,0 +1,66 @@ +name: 'Automated Documentation Audit' + +on: + schedule: + # Runs every Monday at 00:00 UTC + - cron: '0 0 * * MON' + workflow_dispatch: + +jobs: + audit-docs: + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + pull-requests: 'write' + + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' + with: + fetch-depth: 0 + ref: 'main' + persist-credentials: false + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version: '20' + + - name: 'Run Docs Audit with Gemini' + id: 'run_gemini' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' + env: + GEMINI_CLI_TRUST_WORKSPACE: true + with: + upload_artifacts: 'true' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + prompt: | + Activate the 'docs-writer' skill. + + **Task:** Execute the docs audit procedure, as defined in your 'docs-auditing.md' reference. + Provide a detailed summary of the changes you make. + + - name: 'Get current date' + id: 'date' + run: | + echo "date=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" + + - name: 'Create Pull Request with Audit Results' + uses: 'peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c' + with: + token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + commit-message: 'docs: weekly audit results for ${{ github.run_id }}' + title: 'Docs audit: ${{ steps.date.outputs.date }}' + body: | + This PR contains the auto-generated documentation audit for the week. It includes a new `audit-results-*.md` file with findings and any direct fixes applied by the agent. + + ### Audit Summary: + ${{ steps.run_gemini.outputs.summary || 'No summary provided.' }} + + Please review the suggestions and merge. + + Related to #25152 + branch: 'docs-audit-${{ github.run_id }}' + base: 'main' + team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers' + delete-branch: true diff --git a/.github/workflows/docs-page-action.yml b/.github/workflows/docs-page-action.yml new file mode 100644 index 0000000000000000000000000000000000000000..60554fb8092f8dc7087bc2197370814bbfb4b2b0 --- /dev/null +++ b/.github/workflows/docs-page-action.yml @@ -0,0 +1,52 @@ +name: 'Deploy GitHub Pages' + +on: + push: + tags: 'v*' + workflow_dispatch: + +permissions: + contents: 'read' + pages: 'write' + id-token: 'write' + +# Allow only one concurrent deployment, skipping runs queued between the run +# in-progress and latest queued. However, do NOT cancel in-progress runs as we +# want to allow these production deployments to complete. +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: false + +jobs: + build: + if: "github.repository == 'google-gemini/gemini-cli' && !contains(github.ref_name, 'nightly')" + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Setup Pages' + uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5 + + - name: 'Build with Jekyll' + uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1 + with: + source: './' + destination: './_site' + + - name: 'Upload artifact' + uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3 + + deploy: + if: "github.repository == 'google-gemini/gemini-cli'" + environment: + name: 'github-pages' + url: '${{ steps.deployment.outputs.page_url }}' + runs-on: 'ubuntu-latest' + needs: 'build' + steps: + - name: 'Deploy to GitHub Pages' + id: 'deployment' + uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4 diff --git a/.github/workflows/docs-rebuild.yml b/.github/workflows/docs-rebuild.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4e2c659733e0c8f358511c3ab07fef93cf62cec --- /dev/null +++ b/.github/workflows/docs-rebuild.yml @@ -0,0 +1,18 @@ +name: 'Trigger Docs Rebuild' +on: + push: + branches: + - 'main' + paths: + - 'docs/**' +jobs: + trigger-rebuild: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + steps: + - name: 'Trigger rebuild' + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d '{}' \ + "${{ secrets.DOCS_REBUILD_URL }}" diff --git a/.github/workflows/eval-pr.yml b/.github/workflows/eval-pr.yml new file mode 100644 index 0000000000000000000000000000000000000000..1dab98b2ee4111228398cc7bc42a9cde8c836a19 --- /dev/null +++ b/.github/workflows/eval-pr.yml @@ -0,0 +1,211 @@ +name: 'Evals: PR Evaluation & Regression' + +on: + pull_request_target: + types: ['opened', 'synchronize', 'reopened', 'ready_for_review'] + paths: + - 'packages/core/src/prompts/**' + - 'packages/core/src/tools/**' + - 'packages/core/src/agents/**' + - 'evals/**' + - '!**/*.test.ts' + - '!**/*.test.tsx' + workflow_dispatch: + +# Prevents multiple runs for the same PR simultaneously (saves tokens) +concurrency: + group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' + cancel-in-progress: true + +permissions: + pull-requests: 'write' + contents: 'read' + actions: 'read' + +jobs: + detect-changes: + name: 'Detect Steering Changes' + runs-on: 'gemini-cli-ubuntu-16-core' + # Security: pull_request_target allows secrets, so we must gate carefully. + # Detection should not run code from the fork. + if: "github.repository == 'google-gemini/gemini-cli' && github.event.pull_request.draft == false" + outputs: + SHOULD_RUN: '${{ steps.detect.outputs.SHOULD_RUN }}' + STEERING_DETECTED: '${{ steps.detect.outputs.STEERING_DETECTED }}' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + # Check out the trusted code from main for detection + fetch-depth: 0 + persist-credentials: false + + - name: 'Detect Steering Changes' + id: 'detect' + env: + # Use the PR's head SHA for comparison without checking it out + PR_HEAD_SHA: '${{ github.event.pull_request.head.sha }}' + run: | + # Fetch the fork's PR branch for analysis + git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head + + # Run the trusted script from main + SHOULD_RUN=$(node scripts/changed_prompt.js) + STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only) + echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT" + echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT" + + - name: 'Notify Approval Required' + if: "steps.detect.outputs.SHOULD_RUN == 'true'" + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + COMMENT_BODY="### ๐Ÿ›‘ Action Required: Evaluation Approval + + Steering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged. + + **Maintainers:** + 1. Go to the [**Workflow Run Summary**]($RUN_URL). + 2. Click the yellow **'Review deployments'** button. + 3. Select the **'eval-gate'** environment and click **'Approve'**. + + Once approved, the evaluation results will be posted here automatically. + + " + + # Check if comment already exists to avoid spamming + COMMENT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("")) | .url' | grep -oE "[0-9]+$" | head -n 1) + + if [ -z "$COMMENT_ID" ]; then + gh pr comment ${{ github.event.pull_request.number }} --body "$COMMENT_BODY" + else + echo "Updating existing notification comment $COMMENT_ID..." + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -F body="$COMMENT_BODY" + fi + + pr-evaluation: + name: 'Evaluate Steering & Regressions' + needs: 'detect-changes' + if: "needs.detect-changes.outputs.SHOULD_RUN == 'true'" + # Manual approval gate via environment + environment: 'eval-gate' + runs-on: 'gemini-cli-ubuntu-16-core' + env: + # CENTRALIZED MODEL LIST + MODEL_LIST: 'gemini-3-flash-preview' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5 + with: + # Check out the fork's PR code for the actual evaluation + # This only runs AFTER manual approval + ref: '${{ github.event.pull_request.head.sha }}' + fetch-depth: 0 + persist-credentials: false + + - name: 'Remove Approval Notification' + # Run even if other steps fail, to ensure we clean up the "Action Required" message + if: 'always()' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + PR_NUMBER: '${{ github.event.pull_request.number }}' + run: | + echo "Debug: PR_NUMBER is '$PR_NUMBER'" + # Search for the notification comment by its hidden tag + COMMENT_ID=$(gh pr view "$PR_NUMBER" --json comments --jq '.comments[] | select(.body | contains("")) | .url' | grep -oE "[0-9]+$" | head -n 1) + if [ -n "$COMMENT_ID" ]; then + echo "Removing notification comment $COMMENT_ID now that run is approved..." + gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" + fi + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Analyze PR Content (Guidance)' + if: "needs.detect-changes.outputs.STEERING_DETECTED == 'true'" + id: 'analysis' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + # Check for behavioral eval changes + EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true) + if [ -z "$EVAL_CHANGES" ]; then + echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT" + fi + + # Check if user is a maintainer + USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission') + if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then + echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT" + fi + + - name: 'Execute Regression Check' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + MODEL_LIST: '${{ env.MODEL_LIST }}' + run: | + # Run the regression check loop. The script saves the report to a file. + node scripts/run_eval_regression.js + + # Use the generated report file if it exists + if [[ -f eval_regression_report.md ]]; then + echo "REPORT_FILE=eval_regression_report.md" >> "$GITHUB_ENV" + fi + + - name: 'Post or Update PR Comment' + if: "always() && (needs.detect-changes.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')" + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + # 1. Build the full comment body + { + if [[ -f eval_regression_report.md ]]; then + cat eval_regression_report.md + echo "" + fi + + if [[ "${{ needs.detect-changes.outputs.STEERING_DETECTED }}" == "true" ]]; then + echo "### ๐Ÿง  Model Steering Guidance" + echo "" + echo "This PR modifies files that affect the model's behavior (prompts, tools, or instructions)." + echo "" + + if [[ "${{ steps.analysis.outputs.MISSING_EVALS }}" == "true" ]]; then + echo "- โš ๏ธ **Consider adding Evals:** No behavioral evaluations (\`evals/*.eval.ts\`) were added or updated in this PR. Consider [adding a test case](https://github.com/google-gemini/gemini-cli/blob/main/evals/README.md#creating-an-evaluation) to verify the new behavior and prevent regressions." + fi + + if [[ "${{ steps.analysis.outputs.IS_MAINTAINER }}" == "true" ]]; then + echo "- ๐Ÿš€ **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging." + fi + fi + + echo "" + echo "---" + echo "*This is an automated guidance message triggered by steering logic signatures.*" + echo "" + } > full_comment.md + + # 2. Find if a comment with our unique tag already exists + # We extract the numeric ID from the URL to ensure compatibility with the REST API + COMMENT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("")) | .url' | grep -oE "[0-9]+$" | head -n 1) + + # 3. Update or Create the comment + if [ -n "$COMMENT_ID" ]; then + echo "Updating existing comment $COMMENT_ID via API..." + gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -F body=@full_comment.md + else + echo "Creating new PR comment..." + gh pr comment ${{ github.event.pull_request.number }} --body-file full_comment.md + fi diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml new file mode 100644 index 0000000000000000000000000000000000000000..23dc1cfdfbdb090469dc3cba81d441ced69c92b6 --- /dev/null +++ b/.github/workflows/eval.yml @@ -0,0 +1,48 @@ +name: 'Eval' + +on: + workflow_dispatch: + +defaults: + run: + shell: 'bash' + +permissions: + contents: 'read' + id-token: 'write' + packages: 'read' + +jobs: + eval: + name: 'Eval' + if: >- + github.repository == 'google-gemini/gemini-cli' + runs-on: 'ubuntu-latest' + container: + image: 'ghcr.io/google-gemini/gemini-cli-swe-agent-eval@sha256:cd5edc4afd2245c1f575e791c0859b3c084a86bb3bd9a6762296da5162b35a8f' + credentials: + username: '${{ github.actor }}' + password: '${{ secrets.GITHUB_TOKEN }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + DEFAULT_VERTEXAI_PROJECT: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + GOOGLE_CLOUD_PROJECT: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + GEMINI_API_KEY: '${{ secrets.EVAL_GEMINI_API_KEY }}' + GCLI_LOCAL_FILE_TELEMETRY: 'True' + EVAL_GCS_BUCKET: '${{ vars.EVAL_GCS_ARTIFACTS_BUCKET }}' + steps: + - name: 'Authenticate to Google Cloud' + id: 'auth' + uses: 'google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed' # ratchet:exclude pin@v2.1.7 + with: + project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + token_format: 'access_token' + access_token_scopes: 'https://www.googleapis.com/auth/cloud-platform' + + - name: 'Run evaluation' + working-directory: '/app' + run: | + poetry run exp_run --experiment-mode=on-demand --branch-or-commit="${GITHUB_REF_NAME}" --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15 + poetry run python agent_prototypes/scripts/parse_gcli_logs_experiment.py --experiment_dir=experiments/adhoc/gcli_temp_exp --gcs-bucket="${EVAL_GCS_BUCKET}" --gcs-path=gh_action_artifacts diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml new file mode 100644 index 0000000000000000000000000000000000000000..2ee064e4ae290b685ed684198c8dc343dea1d181 --- /dev/null +++ b/.github/workflows/evals-nightly.yml @@ -0,0 +1,121 @@ +name: 'Evals: Nightly' + +on: + schedule: + - cron: '0 1 * * *' # Runs at 1 AM every day + workflow_dispatch: + inputs: + suite_type: + description: 'Suite type to run' + type: 'choice' + options: + - 'behavioral' + - 'component-level' + - 'hero-scenario' + default: 'behavioral' + suite_name: + description: 'Specific suite name to run' + required: false + type: 'string' + test_name_pattern: + description: 'Test name pattern or file name' + required: false + type: 'string' + +permissions: + contents: 'read' + checks: 'write' + actions: 'read' + +jobs: + evals: + name: 'Evals (USUALLY_PASSING) nightly run' + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" + strategy: + fail-fast: false + matrix: + model: + - 'gemini-3.1-pro-preview-customtools' + - 'gemini-3-pro-preview' + - 'gemini-3-flash-preview' + - 'gemini-2.5-pro' + - 'gemini-2.5-flash' + - 'gemini-2.5-flash-lite' + run_attempt: [1, 2, 3] + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Create logs directory' + run: 'mkdir -p evals/logs' + + - name: 'Run Evals' + continue-on-error: true + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + GEMINI_MODEL: '${{ matrix.model }}' + RUN_EVALS: 'true' + EVAL_SUITE_TYPE: "${{ github.event.inputs.suite_type || 'behavioral' }}" + EVAL_SUITE_NAME: '${{ github.event.inputs.suite_name }}' + TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + # Disable Vitest internal retries to avoid double-retrying; + # custom retry logic is handled in evals/test-helper.ts + VITEST_RETRY: 0 + run: | + CMD="npm run test:all_evals" + PATTERN="${TEST_NAME_PATTERN}" + + if [[ -n "$PATTERN" ]]; then + if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then + $CMD -- "$PATTERN" + else + $CMD -- -t "$PATTERN" + fi + else + $CMD + fi + + - name: 'Upload Logs' + if: 'always()' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'eval-logs-${{ matrix.model }}-${{ matrix.run_attempt }}' + path: 'evals/logs' + retention-days: 7 + + aggregate-results: + name: 'Aggregate Results' + needs: ['evals'] + if: "github.repository == 'google-gemini/gemini-cli' && always()" + runs-on: 'gemini-cli-ubuntu-16-core' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Download Logs' + uses: 'actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806' # ratchet:actions/download-artifact@v4 + with: + path: 'artifacts' + + - name: 'Generate Summary' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node scripts/aggregate_evals.js artifacts >> "$GITHUB_STEP_SUMMARY"' diff --git a/.github/workflows/gemini-automated-issue-dedup.yml b/.github/workflows/gemini-automated-issue-dedup.yml new file mode 100644 index 0000000000000000000000000000000000000000..ff64f768973198ab03d6450cf6df1938e97d98c4 --- /dev/null +++ b/.github/workflows/gemini-automated-issue-dedup.yml @@ -0,0 +1,267 @@ +name: '๐Ÿท๏ธ Gemini Automated Issue Deduplication' + +on: + issues: + types: + - 'opened' + - 'reopened' + issue_comment: + types: + - 'created' + workflow_dispatch: + inputs: + issue_number: + description: 'issue number to dedup' + required: true + type: 'number' + +concurrency: + group: '${{ github.workflow }}-${{ github.event.issue.number }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +jobs: + find-duplicates: + if: |- + github.repository == 'google-gemini/gemini-cli' && + vars.TRIAGE_DEDUPLICATE_ISSUES != '' && + (github.event_name == 'issues' || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@gemini-cli /deduplicate') && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR'))) + permissions: + contents: 'read' + id-token: 'write' # Required for WIF, see https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform#adding-permissions-settings + issues: 'read' + statuses: 'read' + packages: 'read' + timeout-minutes: 20 + runs-on: 'ubuntu-latest' + outputs: + duplicate_issues_csv: '${{ env.DUPLICATE_ISSUES_CSV }}' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Log in to GitHub Container Registry' + uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 + with: + registry: 'ghcr.io' + username: '${{ github.actor }}' + password: '${{ secrets.GITHUB_TOKEN }}' + + - name: 'Find Duplicate Issues' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + id: 'gemini_issue_deduplication' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ISSUE_TITLE: '${{ github.event.issue.title }}' + ISSUE_BODY: '${{ github.event.issue.body }}' + ISSUE_NUMBER: '${{ github.event.issue.number }}' + REPOSITORY: '${{ github.repository }}' + FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}' + GEMINI_CLI_TRUST_WORKSPACE: 'true' + with: + upload_artifacts: 'true' + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- + { + "mcpServers": { + "issue_deduplication": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "--network", "host", + "-e", "GITHUB_TOKEN", + "-e", "GEMINI_API_KEY", + "-e", "DATABASE_TYPE", + "-e", "FIRESTORE_DATABASE_ID", + "-e", "GCP_PROJECT", + "-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json", + "-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json", + "ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3" + ], + "env": { + "GITHUB_TOKEN": "${GITHUB_TOKEN}", + "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", + "DATABASE_TYPE":"firestore", + "GCP_PROJECT": "${FIRESTORE_PROJECT}", + "FIRESTORE_DATABASE_ID": "(default)", + "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" + }, + "timeout": 600000 + } + }, + "maxSessionTurns": 25, + "tools": { + "core": [ + "run_shell_command(echo)", + "run_shell_command(gh issue view)" + ] + }, + "telemetry": { + "enabled": true, + "target": "gcp" + } + } + prompt: |- + ## Role + You are an issue de-duplication assistant. Your goal is to find + duplicate issues for a given issue. + ## Steps + 1. **Find Potential Duplicates:** + - The repository is ${{ github.repository }} and the issue number is ${{ github.event.issue.number }}. + - Use the `duplicates` tool with the `repo` and `issue_number` to find potential duplicates for the current issue. Do not use the `threshold` parameter. + - If no duplicates are found, you are done. + - Print the JSON output from the `duplicates` tool to the logs. + 2. **Refine Duplicates List (if necessary):** + - If the `duplicates` tool returns between 1 and 14 results, you must refine the list. + - For each potential duplicate issue, run `gh issue view --json title,body,comments` to fetch its content. + - Also fetch the content of the original issue: `gh issue view "${ISSUE_NUMBER}" --json title,body,comments`. + - Carefully analyze the content (title, body, comments) of the original issue and all potential duplicates. + - It is very important if the comments on either issue mention that they are not duplicates of each other, to treat them as not duplicates. + - Based on your analysis, create a final list containing only the issues you are highly confident are actual duplicates. + - If your final list is empty, you are done. + - Print to the logs if you omitted any potential duplicates based on your analysis. + - If the `duplicates` tool returned 15+ results, use the top 15 matches (based on descending similarity score value) to perform this step. + 3. **Output final duplicates list as CSV:** + - Convert the list of appropriate duplicate issue numbers into a comma-separated list (CSV). If there are no appropriate duplicates, use the empty string. + - Use the "echo" shell command to append the CSV of issue numbers into the filepath referenced by the environment variable "${GITHUB_ENV}": + echo "DUPLICATE_ISSUES_CSV=[DUPLICATE_ISSUES_AS_CSV]" >> "${GITHUB_ENV}" + ## Guidelines + - Only use the `duplicates` and `run_shell_command` tools. + - The `run_shell_command` tool can be used with `gh issue view`. + - Do not download or read media files like images, videos, or links. The `--json` flag for `gh issue view` will prevent this. + - Do not modify the issue content or status. + - Do not add comments or labels. + - Reference all shell variables as "${VAR}" (with quotes and braces). + + add-comment-and-label: + needs: 'find-duplicates' + if: |- + github.repository == 'google-gemini/gemini-cli' && + vars.TRIAGE_DEDUPLICATE_ISSUES != '' && + needs.find-duplicates.outputs.duplicate_issues_csv != '' && + ( + github.event_name == 'issues' || + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@gemini-cli /deduplicate') && + ( + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' + ) + ) + ) + permissions: + issues: 'write' + timeout-minutes: 5 + runs-on: 'ubuntu-latest' + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + permission-issues: 'write' + + - name: 'Comment and Label Duplicate Issue' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + env: + DUPLICATES_OUTPUT: '${{ needs.find-duplicates.outputs.duplicate_issues_csv }}' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + const rawCsv = process.env.DUPLICATES_OUTPUT; + core.info(`Raw duplicates CSV: ${rawCsv}`); + const duplicateIssues = rawCsv.split(',').map(s => s.trim()).filter(s => s); + + if (duplicateIssues.length === 0) { + core.info('No duplicate issues found. Nothing to do.'); + return; + } + + const issueNumber = ${{ github.event.issue.number }}; + + function formatCommentBody(issues, updated = false) { + const header = updated + ? 'Found possible duplicate issues (updated):' + : 'Found possible duplicate issues:'; + const issuesList = issues.map(num => `- #${num}`).join('\n'); + const footer = 'If you believe this is not a duplicate, please remove the `status/possible-duplicate` label.'; + const magicComment = ''; + return `${header}\n\n${issuesList}\n\n${footer}\n${magicComment}`; + } + + const newCommentBody = formatCommentBody(duplicateIssues); + const newUpdatedCommentBody = formatCommentBody(duplicateIssues, true); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + + const magicComment = ''; + const existingComment = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes(magicComment) + ); + + let commentMade = false; + + if (existingComment) { + // To check if lists are same, just compare the formatted bodies without headers. + const existingBodyForCompare = existingComment.body.substring(existingComment.body.indexOf('- #')); + const newBodyForCompare = newCommentBody.substring(newCommentBody.indexOf('- #')); + + if (existingBodyForCompare.trim() !== newBodyForCompare.trim()) { + core.info(`Updating existing comment ${existingComment.id}`); + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: newUpdatedCommentBody, + }); + commentMade = true; + } else { + core.info('Existing comment is up-to-date. Nothing to do.'); + } + } else { + core.info('Creating new comment.'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: newCommentBody, + }); + commentMade = true; + } + + if (commentMade) { + core.info('Adding "status/possible-duplicate" label.'); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: ['status/possible-duplicate'], + }); + } diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml new file mode 100644 index 0000000000000000000000000000000000000000..631c6498ee13a45e0ebc8310de8a7393a396fa03 --- /dev/null +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -0,0 +1,392 @@ +name: '๐Ÿท๏ธ Gemini Automated Issue Triage' + +on: + issues: + types: + - 'opened' + - 'reopened' + issue_comment: + types: + - 'created' + workflow_dispatch: + inputs: + issue_number: + description: 'issue number to triage' + required: true + type: 'number' + workflow_call: + inputs: + issue_number: + description: 'issue number to triage' + required: false + type: 'string' + +concurrency: + group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || inputs.issue_number }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + statuses: 'write' + packages: 'read' + actions: 'write' # Required for cancelling a workflow run + +jobs: + triage-issue: + if: |- + (github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli') && + ( + github.event_name == 'workflow_dispatch' || + ( + (github.event_name == 'issues' || github.event_name == 'issue_comment') && + (github.event_name != 'issue_comment' || ( + contains(github.event.comment.body, '@gemini-cli /triage') && + (github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR') + )) + ) + ) && + !contains(github.event.issue.labels.*.name, 'area/') + timeout-minutes: 5 + runs-on: 'ubuntu-latest' + steps: + - name: 'Get issue data for manual trigger' + id: 'get_issue_data' + if: |- + github.event_name == 'workflow_dispatch' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + const issueNumber = ${{ github.event.inputs.issue_number || inputs.issue_number }}; + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + core.setOutput('title', issue.title); + core.setOutput('body', issue.body); + core.setOutput('labels', issue.labels.map(label => label.name).join(',')); + return issue; + + - name: 'Manual Trigger Pre-flight Checks' + if: |- + github.event_name == 'workflow_dispatch' + env: + ISSUE_NUMBER_INPUT: '${{ github.event.inputs.issue_number || inputs.issue_number }}' + LABELS: '${{ steps.get_issue_data.outputs.labels }}' + run: | + if echo "${LABELS}" | grep -q 'area/'; then + echo "Issue #${ISSUE_NUMBER_INPUT} already has 'area/' label. Stopping workflow." + exit 1 + fi + + echo "Manual triage checks passed." + + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Generate GitHub App Token' + id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + permission-issues: 'write' + + - name: 'Get Repository Labels' + id: 'get_labels' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + const { data: labels } = await github.rest.issues.listLabelsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + const allowedLabels = [ + 'area/agent', + 'area/enterprise', + 'area/non-interactive', + 'area/core', + 'area/security', + 'area/platform', + 'area/extensions', + 'area/documentation', + 'area/unknown' + ]; + const labelNames = labels.map(label => label.name).filter(name => allowedLabels.includes(name)); + core.setOutput('available_labels', labelNames.join(',')); + core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); + return labelNames; + + - name: 'Prepare Issue Data' + id: 'prepare_issue_data' + env: + ISSUE_TITLE: >- + ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.title || github.event.issue.title }} + ISSUE_BODY: >- + ${{ github.event_name == 'workflow_dispatch' && steps.get_issue_data.outputs.body || github.event.issue.body }} + run: | + set -euo pipefail + echo "Title: ${ISSUE_TITLE}" > issue_context.md + echo "Body:" >> issue_context.md + echo "${ISSUE_BODY}" >> issue_context.md + + - name: 'Run Gemini Issue Analysis' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + id: 'gemini_issue_analysis' + env: + GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs + ISSUE_NUMBER: >- + ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.issue_number || inputs.issue_number) || github.event.issue.number }} + REPOSITORY: '${{ github.repository }}' + AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' + GEMINI_CLI_TRUST_WORKSPACE: 'true' + with: + upload_artifacts: 'true' + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- + { + "maxSessionTurns": 25, + "telemetry": { + "enabled": true, + "target": "gcp" + }, + "tools": { + "core": [ + "run_shell_command(echo)", + "read_file" + ] + } + } + prompt: |- + ## Role + + You are an issue triage assistant. Your role is to analyze a GitHub issue and determine the single most appropriate area/ label based on the definitions provided. + + ## Steps + 1. Use the read_file tool to read the file "issue_context.md" which contains the issue title and body. + 2. Review the available labels: ${{ env.AVAILABLE_LABELS }}. + 3. Select exactly one area/ label that best matches the issue based on Reference 1: Area Definitions. + 4. Fallback Logic: + - If you cannot confidently determine the correct area/ label from the definitions, you must use area/unknown. + 5. Output your selected label in JSON format and nothing else. Example: + {"labels_to_set": ["area/core"]} + + ## Guidelines + - Your output must contain exactly one area/ label. + - Triage only the current issue based on its title and body. + - Output only valid JSON format. + - Do not include any explanation or additional text, just the JSON. + + Reference 1: Area Definitions + area/agent + - Description: Issues related to the "brain" of the CLI. This includes the core agent logic, model quality, tool/function calling, and memory. + - Example Issues: + "I am not getting a reasonable or expected response." + "The model is not calling the tool I expected." + "The web search tool is not working as expected." + "Feature request for a new built-in tool (e.g., read file, write file)." + "The generated code is poor quality or incorrect." + "The model seems stuck in a loop." + "The response from the model is malformed (e.g., broken JSON, bad formatting)." + "Concerns about unnecessary token consumption." + "Issues with how memory or chat history is managed." + "Issues with sub-agents." + "Model is switching from one to another unexpectedly." + + area/enterprise + - Description: Issues specific to enterprise-level features, including telemetry, policy, and licenses. + - Example Issues: + "Usage data is not appearing in our telemetry dashboard." + "A user is able to perform an action that should be blocked by an admin policy." + "Questions about billing, licensing tiers, or enterprise quotas." + + area/non-interactive + - Description: Issues related to using the CLI in automated or non-interactive environments (headless mode). + - Example Issues: + "Problems using the CLI as an SDK in another surface." + "The CLI is behaving differently when run from a shell script vs. an interactive terminal." + "GitHub action is failing." + "I am having trouble running the CLI in headless mode" + + area/core + - Description: Issues with the fundamental CLI app itself. This includes the user interface (UI/UX), installation, OS compatibility, and performance. + - Example Issues: + "I am seeing my screen flicker when using the CLI." + "The output in my terminal is malformed or unreadable." + "Theme changes are not taking effect." + "Keyboard inputs (e.g., arrow keys, Ctrl+C) are not being recognized." + "The CLI failed to install or update." + "An issue specific to running on Windows, macOS, or Linux." + "Problems with command parsing, flags, or argument handling." + "High CPU or memory usage by the CLI process." + "Issues related to multi-modality (e.g., handling image inputs)." + "Problems with the IDE integration connection or installation" + + area/security + - Description: Issues related to user authentication, authorization, data security, and privacy. + - Example Issues: + "I am unable to sign in." + "The login flow is selecting the wrong authentication path" + "Problems with API key handling or credential storage." + "A report of a security vulnerability" + "Concerns about data sanitization or potential data leaks." + "Issues or requests related to privacy controls." + "Preventing unauthorized data access." + + area/platform + - Description: Issues related to CI/CD, release management, testing, eval infrastructure, capacity, quota management, and sandbox environments. + - Example Issues: + "I am getting a 429 'Resource Exhausted' or 500-level server error." + "General slowness or high latency from the service." + "The build script is broken on the main branch." + "Tests are failing in the CI/CD pipeline." + "Issues with the release management or publishing process." + "User is running out of capacity." + "Problems specific to the sandbox or staging environments." + "Questions about quota limits or requests for increases." + + area/extensions + - Description: Issues related to the extension ecosystem, including the marketplace and website. + - Example Issues: + "Bugs related to the extension marketplace website." + "Issues with a specific extension." + "Feature request for the extension ecosystem." + + area/documentation + - Description: Issues related to user-facing documentation and other content on the documentation website. + - Example Issues: + "A typo in a README file." + "DOCS: A command is not working as described in the documentation." + "A request for a new documentation page." + "Instructions missing for skills feature" + + area/unknown + - Description: Issues that do not clearly fit into any other defined area/ category, or where information is too limited to make a determination. Use this when no other area is appropriate. + + - name: 'Apply Labels to Issue' + if: |- + ${{ steps.gemini_issue_analysis.outputs.summary != '' }} + env: + REPOSITORY: '${{ github.repository }}' + ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' + LABELS_OUTPUT: '${{ steps.gemini_issue_analysis.outputs.summary }}' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: | + const rawOutput = process.env.LABELS_OUTPUT; + core.info(`Raw output from model: ${rawOutput}`); + let parsedLabels; + try { + // First, try to parse the raw output as JSON. + parsedLabels = JSON.parse(rawOutput); + } catch (jsonError) { + // If that fails, check for a markdown code block. + core.warning(`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`); + const jsonMatch = rawOutput.match(/```json\s*([\s\S]*?)\s*```/); + if (jsonMatch && jsonMatch[1]) { + try { + parsedLabels = JSON.parse(jsonMatch[1].trim()); + } catch (markdownError) { + core.setFailed(`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawOutput}`); + return; + } + } else { + // If no markdown block, try to find a raw JSON object in the output. + // The CLI may include debug/log lines (e.g. telemetry init, YOLO mode) + // before the actual JSON response. + const jsonObjectMatch = rawOutput.match(/(\{[\s\S]*"labels_to_set"[\s\S]*\})/); + if (jsonObjectMatch) { + try { + parsedLabels = JSON.parse(jsonObjectMatch[0]); + } catch (extractError) { + core.setFailed(`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawOutput}`); + return; + } + } else { + core.setFailed(`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawOutput}`); + return; + } + } + } + + const issueNumber = parseInt(process.env.ISSUE_NUMBER); + const labelsToAdd = parsedLabels.labels_to_set || []; + + if (labelsToAdd.length !== 1) { + core.setFailed(`Expected exactly 1 label (area/), but got ${labelsToAdd.length}. Labels: ${labelsToAdd.join(', ')}`); + return; + } + + const newAreaLabel = labelsToAdd[0]; + + // Get current labels to resolve conflicts + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + const currentLabelNames = currentLabels.map(l => l.name); + const currentAreaLabels = currentLabelNames.filter(name => name.startsWith('area/')); + + const labelsToRemove = currentAreaLabels.filter(name => name !== newAreaLabel); + + for (const label of labelsToRemove) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + name: label + }); + core.info(`Removed conflicting area label: ${label}`); + } catch (e) { + core.warning(`Failed to remove label ${label}: ${e.message}`); + } + } + + // Set labels based on triage result + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [newAreaLabel] + }); + core.info(`Successfully added labels for #${issueNumber}: ${newAreaLabel}`); + + - name: 'Post Issue Analysis Failure Comment' + if: |- + ${{ failure() && steps.gemini_issue_analysis.outcome == 'failure' }} + env: + ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' + RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: parseInt(process.env.ISSUE_NUMBER), + body: 'There is a problem with the Gemini CLI issue triaging. Please check the [action logs](${process.env.RUN_URL}) for details.' + }) diff --git a/.github/workflows/gemini-cli-bot-brain.yml b/.github/workflows/gemini-cli-bot-brain.yml new file mode 100644 index 0000000000000000000000000000000000000000..88e2c9231ddd5f1f4a699484bd35f95ab1e9217a --- /dev/null +++ b/.github/workflows/gemini-cli-bot-brain.yml @@ -0,0 +1,350 @@ +name: '๐Ÿง  Gemini CLI Bot: Brain' + +on: + schedule: + - cron: '0 0 * * *' # Every 24 hours + issue_comment: + types: ['created'] + workflow_dispatch: + inputs: + run_interactive: + description: 'Run interactive flow (requires issue_number)' + type: 'boolean' + default: false + issue_number: + description: 'Issue/PR number to simulate context from' + type: 'string' + required: false + comment_id: + description: 'Specific comment ID to simulate' + type: 'string' + required: false + clear_memory: + description: 'Clear memory (drops learnings from previous runs)' + type: 'boolean' + default: false + enable_prs: + description: 'Enable PRs (automatically promote changes to PRs)' + type: 'boolean' + default: false + +concurrency: + group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || github.ref }}' + cancel-in-progress: true + +jobs: + reasoning: + name: 'Brain (Reasoning Layer)' + runs-on: 'ubuntu-latest' + if: | + github.repository == 'google-gemini/gemini-cli' && ( + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive != 'true') || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive == 'true') || + (github.event_name == 'issue_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association)) + ) + # The reasoning phase is strictly readonly. + permissions: + contents: 'read' + issues: 'read' + actions: 'read' + env: + GEMINI_CLI_TRUST_WORKSPACE: 'true' + steps: + - name: 'Determine Checkout Ref' + id: 'determine_ref' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' + run: | + REF="${{ github.ref }}" + if [ -n "$ISSUE_NUMBER" ]; then + PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "") + if [ -n "$PR_HEAD" ]; then + REF="$PR_HEAD" + fi + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ steps.determine_ref.outputs.ref }}' + fetch-depth: 0 + persist-credentials: false + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build Gemini CLI' + run: 'npm run bundle' + + - name: 'Download Previous State' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + if [ "${{ github.event.inputs.clear_memory }}" = "true" ]; then + echo "Memory clear requested. Skipping previous state download." + exit 0 + fi + + # Find the last successful run of this workflow + LAST_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId') + + if [ -n "$LAST_RUN_ID" ]; then + echo "Found previous successful run: $LAST_RUN_ID" + + # Download brain memory to a temp dir so we can selectively restore only persistent state + mkdir -p .temp_brain_data + gh run download "$LAST_RUN_ID" -n brain-data -D .temp_brain_data || echo "brain-data not found" + + # Restore only persistent memory files + cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true + mkdir -p tools/gemini-cli-bot/history/ + cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv tools/gemini-cli-bot/history/ 2>/dev/null || true + rm -rf .temp_brain_data + else + echo "No previous successful run found." + fi + + - name: 'Collect Current Metrics' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts' + + - name: 'Run Brain Phases' + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GEMINI_MODEL: 'gemini-3-flash-preview' + GEMINI_CLI_HOME: 'tools/gemini-cli-bot' + ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'false' }}" + TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' + TRIGGER_COMMENT_ID: '${{ github.event.comment.id || github.event.inputs.comment_id }}' + run: | + PROMPT_PATH="tools/gemini-cli-bot/brain/scheduled.md" + if [ "${{ github.event_name }}" = "issue_comment" ] || [ "${{ github.event.inputs.run_interactive }}" = "true" ]; then + PROMPT_PATH="tools/gemini-cli-bot/brain/interactive.md" + export ENABLE_PRS="true" + fi + + touch trigger_context.md + if [ -n "$TRIGGER_ISSUE_NUMBER" ]; then + echo "" > trigger_context.md + echo "# Interactive Trigger Context" >> trigger_context.md + echo "You were invoked by a user in issue/PR #$TRIGGER_ISSUE_NUMBER." >> trigger_context.md + + if [ -n "$TRIGGER_COMMENT_ID" ]; then + echo "## User Comment" >> trigger_context.md + gh api "repos/${{ github.repository }}/issues/comments/$TRIGGER_COMMENT_ID" -q '.body' >> trigger_context.md 2>/dev/null || gh api "repos/${{ github.repository }}/pulls/comments/$TRIGGER_COMMENT_ID" -q '.body' >> trigger_context.md + echo "" >> trigger_context.md + fi + + echo "## Issue/PR Context" >> trigger_context.md + gh issue view "$TRIGGER_ISSUE_NUMBER" >> trigger_context.md 2>/dev/null || gh pr view "$TRIGGER_ISSUE_NUMBER" >> trigger_context.md + echo "" >> trigger_context.md + fi + + if [ "$ENABLE_PRS" = "true" ]; then + echo "**System Directive**: PR creation is ENABLED for this run. You MUST activate the **'prs' skill** to stage your changes and generate a \`pr-description.md\` file if you are proposing fixes." >> trigger_context.md + echo "**CRITICAL System Directive**: You MUST ONLY propose and implement a **SINGLE** improvement or fix per run. Bundling unrelated changes (e.g., a documentation update and a script fix, or a metrics update and a logic fix) into a single PR is STRICTLY FORBIDDEN and will result in immediate rejection during the critique phase. If you identify multiple issues, pick the most impactful one and ignore the others for now." >> trigger_context.md + else + echo "**System Directive**: PR creation is DISABLED for this run. You MUST NOT stage files or attempt to create a PR description." >> trigger_context.md + fi + echo "" >> trigger_context.md + + cat trigger_context.md "$PROMPT_PATH" > combined_prompt.md + node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml --prompt="$(cat combined_prompt.md)" + + if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then + echo "Agent failed to respond. Generating fallback error message." + echo "โš ๏ธ **Gemini CLI Bot failed to generate a response.**" > "issue-comment.md" + echo "" >> "issue-comment.md" + echo "I encountered an error or failed to generate a complete response to your request. You can check the [GitHub Actions Run Log](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details on what went wrong." >> "issue-comment.md" + fi + + - name: 'Run Critique Phase' + if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GEMINI_MODEL: 'gemini-3-flash-preview' + GEMINI_CLI_HOME: 'tools/gemini-cli-bot' + run: | + if git diff --staged --quiet; then + echo "No changes staged. Skipping critique." + echo "[APPROVED]" > critique_result.txt + else + node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml --prompt="$(cat tools/gemini-cli-bot/.gemini/skills/critique/SKILL.md)" 2>&1 | tee critique_output.log + + if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then + echo "[APPROVED]" > critique_result.txt + else + echo "Critique failed, rejected, or did not explicitly approve changes. Skipping PR creation." + echo "[REJECTED]" > critique_result.txt + fi + fi + + - name: 'Generate Patch' + if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}" + run: | + touch bot-changes.patch + touch pr-description.md + if [ -f critique_result.txt ] && grep -q "\[APPROVED\]" critique_result.txt && ! grep -q "\[REJECTED\]" critique_result.txt; then + git diff --staged > bot-changes.patch + else + echo "Critique did not approve. Skipping patch generation." + fi + + - name: 'Archive Brain Data' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'brain-data' + path: | + tools/gemini-cli-bot/lessons-learned.md + tools/gemini-cli-bot/history/*.csv + bot-changes.patch + pr-description.md + branch-name.txt + pr-comment.md + pr-number.txt + issue-comment.md + retention-days: 90 + + publish: + name: 'Publish Artifacts (Archive Layer)' + needs: 'reasoning' + runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" + # The publish phase is for archiving artifacts and optionally creating PRs. + permissions: + contents: 'write' + pull-requests: 'write' + actions: 'write' + steps: + - name: 'Generate GitHub App Token ๐Ÿ”‘' + id: 'generate_token' + if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}" + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + owner: '${{ github.repository_owner }}' + repositories: '${{ github.event.repository.name }}' + permission-contents: 'write' + permission-pull-requests: 'write' + permission-issues: 'write' + + - name: 'Determine Checkout Ref' + id: 'determine_ref' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' + run: | + REF="main" + if [ -n "$ISSUE_NUMBER" ]; then + PR_HEAD=$(gh pr view "$ISSUE_NUMBER" --repo "${{ github.repository }}" --json headRefName --jq .headRefName 2>/dev/null || echo "") + if [ -n "$PR_HEAD" ]; then + REF="$PR_HEAD" + fi + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ steps.determine_ref.outputs.ref }}' + fetch-depth: 0 + persist-credentials: false + + - name: 'Download Brain Data' + uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4 + with: + name: 'brain-data' + path: '${{ runner.temp }}/brain-data/' + + - name: 'Create or Update PR' + if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event.inputs.run_interactive == 'true' }}" + env: + GH_TOKEN: '${{ steps.generate_token.outputs.token }}' + FALLBACK_PAT: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + run: | + if [ -s "${{ runner.temp }}/brain-data/bot-changes.patch" ]; then + git config user.name "gemini-cli[bot]" + git config user.email "gemini-cli[bot]@users.noreply.github.com" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + + BRANCH_NAME="bot/productivity-updates-$(date +'%Y%m%d%H%M%S')-${{ github.run_id }}" + if [ -f "${{ runner.temp }}/brain-data/branch-name.txt" ]; then + BRANCH_NAME=$(cat "${{ runner.temp }}/brain-data/branch-name.txt") + fi + + if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then + echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort." + exit 1 + fi + + git checkout -B "$BRANCH_NAME" + git apply "${{ runner.temp }}/brain-data/bot-changes.patch" + git add . + + if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then + git commit -F "${{ runner.temp }}/brain-data/pr-description.md" + else + git commit -m "๐Ÿค– Gemini Bot Productivity Optimizations" + fi + + PR_TITLE="๐Ÿค– Gemini Bot Productivity Optimizations" + if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then + PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md") + fi + + if ! git push origin "$BRANCH_NAME" --force; then + echo "Push failed. Retrying with FALLBACK_PAT..." + export GH_TOKEN="$FALLBACK_PAT" + git remote set-url origin "https://x-access-token:${FALLBACK_PAT}@github.com/${{ github.repository }}.git" + git push origin "$BRANCH_NAME" --force + fi + + if ! gh pr view "$BRANCH_NAME" > /dev/null 2>&1; then + gh pr create --draft --title "$PR_TITLE" --body-file "${{ runner.temp }}/brain-data/pr-description.md" --head "$BRANCH_NAME" --base main || \ + gh pr create --draft --title "๐Ÿค– Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$BRANCH_NAME" --base main + else + PR_STATE=$(gh pr view "$BRANCH_NAME" --json state --jq .state) + if [ "$PR_STATE" = "CLOSED" ]; then + NEW_BRANCH_NAME="${BRANCH_NAME}-retry-${{ github.run_id }}" + git checkout -b "$NEW_BRANCH_NAME" + git push origin "$NEW_BRANCH_NAME" --force + gh pr create --draft --title "$PR_TITLE" --body-file "${{ runner.temp }}/brain-data/pr-description.md" --head "$NEW_BRANCH_NAME" --base main || \ + gh pr create --draft --title "๐Ÿค– Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$NEW_BRANCH_NAME" --base main + fi + fi + fi + + - name: 'Post PR/Issue Comment' + env: + GH_TOKEN: '${{ steps.generate_token.outputs.token }}' + TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}' + run: | + if [ -s "${{ runner.temp }}/brain-data/issue-comment.md" ] && [ -n "$TRIGGER_ISSUE_NUMBER" ]; then + echo "Posting comment to triggering issue #$TRIGGER_ISSUE_NUMBER" + # Use REST API (gh api) instead of GraphQL (gh issue comment) to ensure robot identity + # while avoiding potential GraphQL-specific authorization hurdles with PATs. + gh api "repos/${{ github.repository }}/issues/$TRIGGER_ISSUE_NUMBER/comments" -F body=@"${{ runner.temp }}/brain-data/issue-comment.md" + fi + + if [ -s "${{ runner.temp }}/brain-data/pr-comment.md" ] && [ -f "${{ runner.temp }}/brain-data/pr-number.txt" ]; then + PR_NUM=$(cat "${{ runner.temp }}/brain-data/pr-number.txt") + + # Using GitHub App, so author check is no longer valid against gemini-cli-robot + # Skipping author validation here to let the app post. + + # Use REST API (gh api) for consistency and robot identity + gh api "repos/${{ github.repository }}/issues/$PR_NUM/comments" -F body=@"${{ runner.temp }}/brain-data/pr-comment.md" + fi diff --git a/.github/workflows/gemini-cli-bot-pulse.yml b/.github/workflows/gemini-cli-bot-pulse.yml new file mode 100644 index 0000000000000000000000000000000000000000..32fb6a0072ad28749ad53889fb209036e67a1da6 --- /dev/null +++ b/.github/workflows/gemini-cli-bot-pulse.yml @@ -0,0 +1,49 @@ +name: '๐Ÿ”„ Gemini CLI Bot: Pulse' + +on: + schedule: + - cron: '*/30 * * * *' # Every 30 minutes + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }}-${{ github.ref }}' + cancel-in-progress: true + +permissions: + contents: 'write' + issues: 'write' + pull-requests: 'write' + +jobs: + pulse: + name: 'Pulse (Reflex Layer)' + runs-on: 'ubuntu-latest' + if: "github.repository == 'google-gemini/gemini-cli'" + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Run Reflex Processes' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + if [ -d "tools/gemini-cli-bot/reflexes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/reflexes/scripts)" ]; then + for script in tools/gemini-cli-bot/reflexes/scripts/*.ts; do + echo "Running reflex script: $script" + npx tsx "$script" + done + else + echo "No reflex scripts found." + fi diff --git a/.github/workflows/gemini-lifecycle-manager.yml b/.github/workflows/gemini-lifecycle-manager.yml new file mode 100644 index 0000000000000000000000000000000000000000..7f0a2b9484aded287a95c84b2562bb2c649c13d4 --- /dev/null +++ b/.github/workflows/gemini-lifecycle-manager.yml @@ -0,0 +1,47 @@ +name: '๐Ÿ”„ Gemini Scheduled Lifecycle Manager' + +on: + schedule: + - cron: '30 1 * * *' # Once a day + workflow_dispatch: + inputs: + dry_run: + description: 'Run in dry-run mode (no changes applied)' + required: false + default: false + type: 'boolean' + +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: true + +permissions: + issues: 'write' + pull-requests: 'write' + +jobs: + manage-lifecycle: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Checkout repository' + uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 + with: + persist-credentials: false + + - name: 'Lifecycle Management' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + env: + DRY_RUN: '${{ inputs.dry_run }}' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const script = require('./.github/scripts/gemini-lifecycle-manager.cjs'); + await script({github, context, core}); diff --git a/.github/workflows/gemini-scheduled-issue-dedup.yml b/.github/workflows/gemini-scheduled-issue-dedup.yml new file mode 100644 index 0000000000000000000000000000000000000000..ef52be84fc35bde632f255d41d69ceb3d2514d88 --- /dev/null +++ b/.github/workflows/gemini-scheduled-issue-dedup.yml @@ -0,0 +1,120 @@ +name: '๐Ÿ“‹ Gemini Scheduled Issue Deduplication' + +on: + schedule: + - cron: '0 * * * *' # Runs every hour + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +jobs: + refresh-embeddings: + if: |- + ${{ vars.TRIAGE_DEDUPLICATE_ISSUES != '' && github.repository == 'google-gemini/gemini-cli' }} + permissions: + contents: 'read' + id-token: 'write' # Required for WIF, see https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-google-cloud-platform#adding-permissions-settings + issues: 'read' + statuses: 'read' + packages: 'read' + timeout-minutes: 20 + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Log in to GitHub Container Registry' + uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 + with: + registry: 'ghcr.io' + username: '${{ github.actor }}' + password: '${{ secrets.GITHUB_TOKEN }}' + + - name: 'Run Gemini Issue Deduplication Refresh' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + id: 'gemini_refresh_embeddings' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ISSUE_TITLE: '${{ github.event.issue.title }}' + ISSUE_BODY: '${{ github.event.issue.body }}' + ISSUE_NUMBER: '${{ github.event.issue.number }}' + REPOSITORY: '${{ github.repository }}' + FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}' + with: + upload_artifacts: 'true' + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- + { + "mcpServers": { + "issue_deduplication": { + "command": "docker", + "args": [ + "run", + "-i", + "--rm", + "--network", "host", + "-e", "GITHUB_TOKEN", + "-e", "GEMINI_API_KEY", + "-e", "DATABASE_TYPE", + "-e", "FIRESTORE_DATABASE_ID", + "-e", "GCP_PROJECT", + "-e", "GOOGLE_APPLICATION_CREDENTIALS=/app/gcp-credentials.json", + "-v", "${GOOGLE_APPLICATION_CREDENTIALS}:/app/gcp-credentials.json", + "ghcr.io/google-gemini/gemini-cli-issue-triage@sha256:e3de1523f6c83aabb3c54b76d08940a2bf42febcb789dd2da6f95169641f94d3" + ], + "env": { + "GITHUB_TOKEN": "${GITHUB_TOKEN}", + "GEMINI_API_KEY": "${{ secrets.GEMINI_API_KEY }}", + "DATABASE_TYPE":"firestore", + "GCP_PROJECT": "${FIRESTORE_PROJECT}", + "FIRESTORE_DATABASE_ID": "(default)", + "GOOGLE_APPLICATION_CREDENTIALS": "${GOOGLE_APPLICATION_CREDENTIALS}" + }, + "timeout": 600000 + } + }, + "maxSessionTurns": 25, + "tools": { + "core": [ + "run_shell_command(echo)" + ] + }, + "telemetry": { + "enabled": true, + "target": "gcp" + } + } + prompt: |- + ## Role + + You are a database maintenance assistant for a GitHub issue deduplication system. + + ## Goal + + Your sole responsibility is to refresh the embeddings for all open issues in the repository to ensure the deduplication database is up-to-date. + + ## Steps + + 1. **Extract Repository Information:** The repository is ${{ github.repository }}. + 2. **Refresh Embeddings:** Call the `refresh` tool with the correct `repo`. Do not use the `force` parameter. + 3. **Log Output:** Print the JSON output from the `refresh` tool to the logs. + + ## Guidelines + + - Only use the `refresh` tool. + - Do not attempt to find duplicates or modify any issues. + - Your only task is to call the `refresh` tool and log its output. diff --git a/.github/workflows/gemini-scheduled-issue-triage.yml b/.github/workflows/gemini-scheduled-issue-triage.yml new file mode 100644 index 0000000000000000000000000000000000000000..c65d0632b007fff41f2cc1ff0ccfddb6ed43b40c --- /dev/null +++ b/.github/workflows/gemini-scheduled-issue-triage.yml @@ -0,0 +1,463 @@ +name: '๐Ÿ“‹ Gemini Scheduled Issue Triage' + +on: + schedule: + - cron: '0 * * * *' # Runs every hour + workflow_dispatch: + +concurrency: + group: '${{ github.workflow }}-${{ github.event.number || github.run_id }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +permissions: + id-token: 'write' + issues: 'write' + +jobs: + triage-issues: + timeout-minutes: 60 + if: |- + ${{ github.repository == 'google-gemini/gemini-cli' }} + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Install Utilities' + run: | + sudo apt-get update + sudo apt-get install -y ripgrep + + - name: 'Get Current Version' + id: 'get_version' + run: | + VERSION=$(jq -r .version package.json | cut -d'-' -f1) + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "๐Ÿš€ Current CLI Version: ${VERSION}" + + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + permission-issues: 'write' + + - name: 'Get issue from event' + if: |- + ${{ github.event_name == 'issues' }} + id: 'get_issue_from_event' + env: + ISSUE_EVENT: '${{ toJSON(github.event.issue) }}' + run: | + set -euo pipefail + echo "$ISSUE_EVENT" | jq -c '[{number: .number, title: .title, body: .body}]' > issues_to_triage.json + echo "has_issues=true" >> "${GITHUB_OUTPUT}" + echo "โœ… Found issue #${{ github.event.issue.number }} from event to triage! ๐ŸŽฏ" + + - name: 'Sync Issue Types' + if: |- + ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: |- + const syncIssueTypes = require('./.github/scripts/sync-issue-types.cjs'); + await syncIssueTypes({ github, context, core }); + + - name: 'Find Issues with Conflicting Labels' + if: |- + ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + env: + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + GITHUB_REPOSITORY: '${{ github.repository }}' + run: |- + set -euo pipefail + echo '๐Ÿ” Fetching open issues to find conflicts...' + # Fetch up to 2000 open issues in one quick GraphQL-backed query + gh issue list --repo "${GITHUB_REPOSITORY}" --search "is:issue is:open" --limit 2000 --json number,title,body,labels > all_open_issues.json + + echo '๐Ÿงน Filtering issues with multiple area/ or priority/ labels...' + jq -c '[ .[] | select( (.labels | map(select(.name | startswith("area/"))) | length) > 1 or (.labels | map(select(.name | startswith("priority/"))) | length) > 1 ) ] | .[0:50]' all_open_issues.json > conflicting_labels_issues.json + + CONFLICT_COUNT=$(jq 'length' conflicting_labels_issues.json) + echo "Found ${CONFLICT_COUNT} issues with conflicting labels (capped at 50 for processing)." + + - name: 'Find untriaged issues' + if: |- + ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + id: 'find_issues' + env: + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + GITHUB_REPOSITORY: '${{ github.repository }}' + run: |- + set -euo pipefail + + echo '๐Ÿ” Finding issues missing area labels...' + gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 50 --json number,title,body,labels > no_area_issues.json + + echo '๐Ÿ” Finding issues missing kind labels...' + gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body,labels > no_kind_issues.json + + echo '๐Ÿท๏ธ Finding issues missing priority labels...' + gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body,labels > no_priority_issues.json + + echo '๐Ÿ“ Finding issues missing effort labels...' + gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 20 --json number,title,body,labels > no_effort_issues.json + + echo '๐Ÿ”„ Merging and deduplicating standard triage issues...' + if [ ! -f conflicting_labels_issues.json ]; then echo "[]" > conflicting_labels_issues.json; fi + jq -c -s 'add | unique_by(.number)' no_area_issues.json no_kind_issues.json no_priority_issues.json conflicting_labels_issues.json > standard_issues_to_triage.json + + echo '๐Ÿ“ Deduplicating effort issues...' + jq -c -s 'add | unique_by(.number)' no_effort_issues.json > effort_issues_to_triage.json + + STANDARD_COUNT="$(jq 'length' standard_issues_to_triage.json)" + EFFORT_COUNT="$(jq 'length' effort_issues_to_triage.json)" + if [ "$STANDARD_COUNT" -gt 0 ] || [ "$EFFORT_COUNT" -gt 0 ]; then + echo "has_issues=true" >> "${GITHUB_OUTPUT}" + echo "has_standard_issues=$([ "$STANDARD_COUNT" -gt 0 ] && echo 'true' || echo 'false')" >> "${GITHUB_OUTPUT}" + echo "has_effort_issues=$([ "$EFFORT_COUNT" -gt 0 ] && echo 'true' || echo 'false')" >> "${GITHUB_OUTPUT}" + else + echo "has_issues=false" >> "${GITHUB_OUTPUT}" + echo "has_standard_issues=false" >> "${GITHUB_OUTPUT}" + echo "has_effort_issues=false" >> "${GITHUB_OUTPUT}" + fi + echo "โœ… Found ${STANDARD_COUNT} standard issues and ${EFFORT_COUNT} effort issues to triage! ๐ŸŽฏ" + + - name: 'Create Gemini CLI Experiments Override' + if: |- + steps.get_issue_from_event.outputs.has_issues == 'true' || steps.find_issues.outputs.has_issues == 'true' + run: | + cat << 'EOF' > gemini_exp.json + { + "flags": [ + { + "flagId": 45750526, + "boolValue": false + } + ], + "experimentIds": [] + } + EOF + + - name: 'Get Repository Labels' + id: 'get_labels' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: |- + const { data: labels } = await github.rest.issues.listLabelsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + }); + const labelNames = labels.map(label => label.name); + core.setOutput('available_labels', labelNames.join(',')); + core.info(`Found ${labelNames.length} labels: ${labelNames.join(', ')}`); + return labelNames; + + - name: 'Run Standard Triage Analysis' + if: |- + steps.get_issue_from_event.outputs.has_issues == 'true' || steps.find_issues.outputs.has_standard_issues == 'true' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + id: 'gemini_standard_issue_analysis' + env: + GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs + REPOSITORY: '${{ github.repository }}' + AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' + CLI_VERSION: '${{ steps.get_version.outputs.version }}' + GEMINI_CLI_TRUST_WORKSPACE: 'true' + GEMINI_EXP: 'gemini_exp.json' + GEMINI_STRICT_TELEMETRY_LIMITS: 'true' + GEMINI_MODEL: 'gemini-3-flash-preview' + with: + upload_artifacts: 'true' + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- + { + "maxSessionTurns": 25, + "tools": { + "core": [ + "run_shell_command(echo)", + "read_file" + ] + }, + "telemetry": { + "enabled": false, + "target": "gcp" + } + } + prompt: |- + ## Role + + You are an issue triage assistant. Analyze issues and identify + appropriate labels. Use the available tools to gather information; + do not ask for information to be provided. + + ## Steps + + 1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}". + 2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage (including their current labels). + 3. Review the issue title, body, current labels, and any comments provided in the JSON file. + 4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, and priority/*. + 5. Label Policy: + - If the issue already has a kind/ label, do not change it. + - If the issue has exactly ONE priority/ label, do not change it (unless you are explicitly re-evaluating an ambiguous priority). + - If the issue is missing a priority/ label, OR if the issue currently has MULTIPLE priority/ labels, you must evaluate the issue's impact to determine exactly ONE priority level (priority/p0, priority/p1, priority/p2, priority/p3, or priority/unknown) based the guidelines. If you are fixing an issue with multiple priority/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`. + - If the issue has exactly ONE area/ label, do not change it. + - If the issue is missing an area/ label, OR if the issue currently has MULTIPLE area/ labels, select exactly ONE area/ label that best fits the issue. Issues MUST NOT have multiple area/ labels. If you are fixing an issue with multiple area/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`. + - If any of these are missing, select exactly ONE appropriate label for the missing category. + 6. Identify other applicable labels based on the issue content, such as status/*, help wanted, good first issue, etc. + 7. Give me a single short explanation about why you are selecting each label in the process. + 8. Output a JSON array of objects, each containing the issue number + and the labels to add and remove, along with an explanation. For example: + ``` + [ + { + "issue_number": 123, + "labels_to_add": ["area/core", "kind/bug", "priority/p2"], + "labels_to_remove": ["status/need-triage"], + "explanation": "This issue is a UI bug that needs to be addressed with medium priority." + } + ] + ``` + If an issue cannot be classified, do not include it in the output array. + 9. For each issue, carefully check if the CLI version is present. It is usually found under the "### Client information" header, as a bullet point (e.g., "โ€ข CLI Version: 0.33.1", "* **CLI Version:** 0.42.0"), or in the output of the `/about` command. + - **Only for issues classified as kind/bug:** If the version is provided but is more than 6 minor versions older than the most recent release (current version is ${{ steps.get_version.outputs.version }}), apply the status/need-information label and leave a comment politely asking the user to verify if the issue persists in the latest version. + 10. **Only for issues classified as kind/bug:** If the issue does not have sufficient information, recommend the status/need-information label and leave a comment politely requesting the missing details. For example, if repro steps are missing, ask for them; if the CLI version is completely missing, ask for the version information in the explanation section below. Do not ask for version info if it is already in the issue body. (Check both bullet points and bold text). For features and enhancements, the CLI version is NOT required. + 11. If you think an issue is a Priority/P0, you MUST apply the priority/p1 label AND the status/manual-triage label, and include a note in your explanation that it likely requires P0 escalation. + 12. If the issue is highly ambiguous, completely lacks a description, or you are torn between two lower priorities (like P2 vs P3), you MUST retain the existing priority label if one is already present. Do not toggle the priority if you do not have enough information to make a definitive change. + 13. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label. + + ## Guidelines + + - Output only valid JSON format + - Do not include any explanation or additional text, just the JSON + - Only use labels that already exist in the repository. + - Do not add comments or modify the issue content. + - Do not remove the following labels maintainer, help wanted or good first issue. + - Triage only the current issue. + - Identify exactly ONE area/ label. Do NOT assign multiple area/ labels to a single issue. + - Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue) + - Identify exactly ONE priority/ label. Do NOT assign multiple priority/ labels to a single issue. + - **Do not manually downgrade the priority.** Always assign the true priority based on the guidelines. The system will handle downgrades programmatically if information is missing. + - **NEVER mention label names, label removals, or label additions in your `explanation`.** The explanation must be purely written for the user (e.g., "Please provide your CLI version.") without exposing internal triage mechanics (e.g., do NOT say "Removing area/unknown to leave only area/core"). + + Categorization Guidelines (Priority): + P0 - Urgent Blocking Issues: + - DO NOT APPLY THE priority/p0 LABEL AUTOMATICALLY. Instead apply priority/p1 and status/manual-triage. + - Definition: Critical failures breaking core functionality for a large portion of users. Examples: CLI fails to launch globally, core commands (gemini run) crash on valid input, unhandled promise rejections on boot, critical security vulnerability. + - Note: You must apply priority/p1 and status/manual-triage instead of priority/p0. + P1 - Critical but Workable: + - Definition: Severe issues without a reasonable workaround, significantly degrading the developer experience but not globally blocking. Examples: Specific tools failing consistently (e.g., `web_search` returns 500s), persistent PTY streaming hangs, memory leaks leading to OOM after short use. + P2 - Significant Issues: + - Definition: Affect some workflows but a clear workaround exists, or non-critical bugs. Examples: Theme flickering, confusing error messages, minor UI misalignment, failing to read deeply nested config files correctly. + P3 - Minor/Enhancements: + - Definition: Trivial bugs, typos, documentation requests, or feature requests. + + Categorization Guidelines (Kind): + kind/bug: The issue is describing an unexpected behavior or failure in the application. + kind/enhancement: The issue is describing a feature request or an improvement to an existing feature. + kind/question: The issue is asking a question about how to use the CLI or about a specific feature. + + Categorization Guidelines (Area): + area/agent: The "brain" of the CLI. Core agent logic, model quality, tool/function calling, memory, web search, generated code quality, sub-agents. + area/core: The fundamental CLI app. UI/UX, installation, OS compatibility, performance, command parsing, theming, flickering. + area/documentation: Website docs, READMEs, inline help text. + area/enterprise: Telemetry, Policy, Quota / Licensing + area/extensions: Gemini CLI extensions capability + area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation + area/platform: Platform specific behavior + area/security: Authentication, authorization, privacy, data leaks, credential storage. + + - name: 'Stop Telemetry Collector' + if: |- + steps.find_issues.outputs.has_effort_issues == 'true' + run: 'docker rm -f gemini-telemetry-collector || true' + + - name: 'Run Effort Triage Analysis' + if: |- + steps.find_issues.outputs.has_effort_issues == 'true' + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + id: 'gemini_effort_issue_analysis' + env: + GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs + REPOSITORY: '${{ github.repository }}' + AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}' + CLI_VERSION: '${{ steps.get_version.outputs.version }}' + GEMINI_CLI_TRUST_WORKSPACE: 'true' + GEMINI_EXP: 'gemini_exp.json' + GEMINI_STRICT_TELEMETRY_LIMITS: 'true' + GEMINI_MODEL: 'gemini-3-flash-preview' + with: + upload_artifacts: 'true' + gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}' + gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}' + gcp_location: '${{ vars.GOOGLE_CLOUD_LOCATION }}' + gcp_service_account: '${{ vars.SERVICE_ACCOUNT_EMAIL }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + use_vertex_ai: '${{ vars.GOOGLE_GENAI_USE_VERTEXAI }}' + use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}' + settings: |- + { + "maxSessionTurns": 30, + "tools": { + "core": [ + "run_shell_command(echo)", + "grep_search", + "glob", + "read_file" + ] + }, + "telemetry": { + "enabled": false, + "target": "gcp" + } + } + prompt: |- + ## Role + + You are an expert software architect. Analyze the provided GitHub issues and assign the correct `effort/*` label based on the codebase complexity. + + ## Steps + + 1. Use the read_file tool to read "effort_issues_to_triage.json". + 2. For each issue in the array: + - You must evaluate the architectural complexity to determine the effort level. You MUST NOT guess the root cause. You MUST actively use your codebase search tools (grep_search and glob) to search for keywords from the issue and explore the codebase. You must identify the specific files and components involved before deciding the effort. + 3. Output a JSON array of objects, each containing the issue number and the effort label to add, along with an explanation and an effort_analysis field. This effort_analysis must be highly detailed, technical, and empirical. It MUST NOT contain vague guesses (e.g., avoid words like "likely points to" or "possibly"). You must explicitly cite the specific file paths and architectural mechanisms you discovered using your search tools, explain the root cause, and then explicitly state how that complexity maps to the chosen effort level guidelines. For example: + ``` + [ + { + "issue_number": 123, + "labels_to_add": ["effort/small"], + "explanation": "This is a simple logic fix.", + "effort_analysis": "The `vscode-ide-companion` extension indiscriminately tracks active text editors via `vscode.window.onDidChangeActiveTextEditor` in `open-files-manager.ts`. When a user opens `.vscode/settings.json`, its content is sent to the CLI's context. The fix is highly localized to the VS Code companion extension's event listener. It involves adding a simple conditional check to exclude specific configuration files from the active editor tracking logic, which is a trivial logic adjustment with a clear root cause." + } + ] + ``` + + ## Guidelines + + - Output only valid JSON format + - Do not include any explanation or additional text, just the JSON + - Triage only the current issue. + + Categorization Guidelines (Effort): + effort/small (1 day or less): + - Trivial Logic & Config: Schema updates (Zod), feature flag toggles, adding missing fields to package.json or settings.json. + - UI/Aesthetic Adjustments: Fixing minor layout bugs in Ink components (e.g., adding flexShrink, correcting padding in a single Box), text color changes. + - Documentation & Strings: Typos, log message updates, CLI argument descriptions. + - Localized Bug Fixes: Single-file logic errors, straightforward promise rejections (e.g., wrapping a known failure in a try/catch), simple regex or string parsing fixes. + effort/medium (2-3 days): + - React/Ink State Management: Debugging useState/useEffect/useReducer bugs, component lifecycle issues (memory leaks in the UI), terminal redraw flickering, or state synchronization between the CLI's internal input buffer and the interactive React components. + - Asynchronous Flow & Integration: Resolving complex Promise chains, ERR_STREAM_PREMATURE_CLOSE, debugging IDE companion extensions (VS Code, Android Studio) or resolving hanging HTTP requests/IPC between the CLI and external plugins, timeouts in non-interactive/ACP modes. + - Tooling & Output Parsers: Modifying how tools parse streaming stdout/stderr buffers, adding new built-in tools that don't require native bindings. + - Cross-Component Refactors: Changes that span across packages/cli and packages/core to pass new data models or telemetry state. + effort/large (3+ days): + - Platform-Specific Complexities (PTY/Signals): Any issue involving node-pty, child_process.spawn, OS-level shell behavior (Windows vs Linux vs macOS), pseudo-terminal exhaustion (ENXIO), raw mode terminal desyncs, or POSIX signal forwarding (SIGINT/SIGTERM). + - Core Architecture & Protocols: Refactoring the Scheduler, Agent-to-Agent (A2A) protocol implementation, low-level MCP (Model Context Protocol) transport mechanisms. + - Performance & Memory: Diagnosing massive disk/memory leaks, severe boot time regressions, high-throughput streaming optimizations (e.g., voice streaming pipelines). + Note: Any bug that is described as intermittent, flickering, difficult to reproduce, platform-specific, or requiring cross-environment setups (e.g., involving the VS Code IDE companion, GCA plugin, or Android Studio) MUST NOT be rated as effort/small because of the increased overhead of testing and reproducing. + + Categorization Guidelines (Priority): + P0 - Urgent Blocking Issues: + - DO NOT APPLY THIS LABEL AUTOMATICALLY. Use status/manual-triage instead. + - Definition: Urgent, block a significant percentage of the user base, and prevent frequent use of the Gemini CLI. + - This includes core stability blockers (e.g., authentication failures, broken upgrades), critical crashes, and P0 security vulnerabilities. + - Impact: Blocks development or testing for the entire team; Major security vulnerability; Causes data loss or corruption with no workaround; Crashes the application or makes a core feature completely unusable for all or most users. + - Qualifier: Is the main function of the software broken? + P1 - High-Impact Issues: + - Definition: Affect a large number of users, blocking them from using parts of the Gemini CLI, or make the CLI frequently unusable even with workarounds available. + - Impact: A core feature is broken or behaving incorrectly for a large number of users or use cases; Severe performance degradation; No straightforward workaround exists. + - Qualifier: Is a key feature unusable or giving very wrong results? + P2 - Significant Issues: + - Definition: Affect some users significantly, such as preventing the use of certain features or authentication types. + - Can also be issues that many users complain about, causing annoyance or hindering daily use. + - Impact: Affects a non-critical feature or a smaller, specific subset of users; An inconvenient but functional workaround is available; Noticeable UI/UX problems that look unprofessional. + - Qualifier: Is it an annoying but non-blocking problem? + P3 - Low-Impact Issues: + - Definition: Typically usability issues that cause annoyance to a limited user base. + - Includes feature requests that could be addressed in the near future and may be suitable for community contributions. + - Impact: Minor cosmetic issues; An edge-case bug that is very difficult to reproduce and affects a tiny fraction of users. + - Qualifier: Is it a "nice-to-fix" issue? + + Categorization Guidelines (Area): + area/agent: Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality + area/core: User Interface, OS Support, Core Functionality + area/documentation: End-user and contributor-facing documentation, website-related + area/enterprise: Telemetry, Policy, Quota / Licensing + area/extensions: Gemini CLI extensions capability + area/non-interactive: GitHub Actions, SDK, 3P Integrations, Shell Scripting, Command line automation + area/platform: Build infra, Release mgmt, Testing, Eval infra, Capacity, Quota mgmt + area/security: security related issues + + Additional Context: + - If users are talking about issues where the model gets downgraded from pro to flash then i want you to categorize that as a performance issue. + - This product is designed to use different models eg.. using pro, downgrading to flash etc. + - When users report that they dont expect the model to change those would be categorized as feature requests. + + - name: 'Apply Triaged Labels' + if: |- + always() && + ( (steps.gemini_standard_issue_analysis.outcome == 'success' && steps.gemini_standard_issue_analysis.outputs.summary != '[]' && steps.gemini_standard_issue_analysis.outputs.summary != '') || + (steps.gemini_effort_issue_analysis.outcome == 'success' && steps.gemini_effort_issue_analysis.outputs.summary != '[]' && steps.gemini_effort_issue_analysis.outputs.summary != '') ) + env: + LABELS_OUTPUT_STANDARD: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}' + LABELS_OUTPUT_EFFORT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: |- + const applyLabels = require('./.github/scripts/apply-issue-labels.cjs'); + await applyLabels({ github, context, core }); + + - name: 'Sync Issue Types (Post-Analysis)' + if: |- + always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: |- + const syncIssueTypes = require('./.github/scripts/sync-issue-types.cjs'); + await syncIssueTypes({ github, context, core }); + + - name: 'Find Triaged Issues to Clean Up' + if: |- + always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + env: + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + GITHUB_REPOSITORY: '${{ github.repository }}' + run: |- + set -euo pipefail + echo '๐Ÿงน Finding issues that have conflicting status labels...' + gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > cleanup_1.json + gh issue list --repo "${GITHUB_REPOSITORY}" \ + --search 'is:open is:issue label:status/bot-triaged label:status/manual-triage' --limit 50 --json number > cleanup_2.json + jq -c -s 'add | unique_by(.number)' cleanup_1.json cleanup_2.json > issues_to_cleanup.json + + - name: 'Clean Up Triage Labels' + if: |- + always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: |- + const cleanupLabels = require('./.github/scripts/cleanup-triage-labels.cjs'); + await cleanupLabels({ github, context, core }); diff --git a/.github/workflows/gemini-scheduled-pr-triage.yml b/.github/workflows/gemini-scheduled-pr-triage.yml new file mode 100644 index 0000000000000000000000000000000000000000..33072519b1b3589181743afa1c01fc583a65354a --- /dev/null +++ b/.github/workflows/gemini-scheduled-pr-triage.yml @@ -0,0 +1,47 @@ +name: 'Gemini Scheduled PR Triage ๐Ÿš€' + +on: + schedule: + - cron: '*/15 * * * *' # Runs every 15 minutes + workflow_dispatch: + +jobs: + audit-prs: + timeout-minutes: 15 + if: |- + ${{ github.repository == 'google-gemini/gemini-cli' }} + permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + pull-requests: 'write' + runs-on: 'ubuntu-latest' + outputs: + prs_needing_comment: '${{ steps.run_triage.outputs.prs_needing_comment }}' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + permission-issues: 'write' + permission-pull-requests: 'write' + + - name: 'Run PR Triage Script' + id: 'run_triage' + shell: 'bash' + env: + GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}' + GITHUB_REPOSITORY: '${{ github.repository }}' + run: |- + ./.github/scripts/pr-triage.sh + # If prs_needing_comment is empty, set it to [] explicitly for downstream steps + if [[ -z "$(grep 'prs_needing_comment' "${GITHUB_OUTPUT}" | cut -d'=' -f2-)" ]]; then + echo "prs_needing_comment=[]" >> "${GITHUB_OUTPUT}" + fi diff --git a/.github/workflows/gemini-self-assign-issue.yml b/.github/workflows/gemini-self-assign-issue.yml new file mode 100644 index 0000000000000000000000000000000000000000..454fc4f41b3359c4a133627ae2f8c455391166bb --- /dev/null +++ b/.github/workflows/gemini-self-assign-issue.yml @@ -0,0 +1,150 @@ +name: 'Assign Issue on Comment' + +on: + issue_comment: + types: + - 'created' + +concurrency: + group: '${{ github.workflow }}-${{ github.event.issue.number }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +permissions: + contents: 'read' + id-token: 'write' + issues: 'write' + statuses: 'write' + packages: 'read' + +jobs: + self-assign-issue: + if: |- + github.repository == 'google-gemini/gemini-cli' && + github.event_name == 'issue_comment' && + (contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign')) + runs-on: 'ubuntu-latest' + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + # Add 'assignments' write permission + permission-issues: 'write' + + - name: 'Assign issue to user' + if: "contains(github.event.comment.body, '/assign')" + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const issueNumber = context.issue.number; + const commenter = context.actor; + const owner = context.repo.owner; + const repo = context.repo.repo; + const MAX_ISSUES_ASSIGNED = 3; + + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + + const hasHelpWantedLabel = issue.data.labels.some(label => label.name === 'help wanted'); + + if (!hasHelpWantedLabel) { + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: issueNumber, + body: `๐Ÿ‘‹ @${commenter}, thanks for your interest in this issue! We're reserving self-assignment for issues that have been marked with the \`help wanted\` label. Feel free to check out our list of [issues that need attention](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).` + }); + return; + } + + // Search for open issues already assigned to the commenter in this repo + const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({ + q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`, + advanced_search: true + }); + + if (assignedIssues.total_count >= MAX_ISSUES_ASSIGNED) { + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: issueNumber, + body: `๐Ÿ‘‹ @${commenter}! You currently have ${assignedIssues.total_count} issues assigned to you. We have a ${MAX_ISSUES_ASSIGNED} max issues assigned at once policy. Once you close out an existing issue it will open up space to take another. You can also unassign yourself from an existing issue but please work on a hand-off if someone is expecting work on that issue.` + }); + return; // exit + } + + if (issue.data.assignees.length > 0) { + // Comment that it's already assigned + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `@${commenter} Thanks for taking interest but this issue is already assigned. We'd still love to have you contribute. Check out our [Help Wanted](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22) list for issues where we need some extra attention.` + }); + return; + } + + // If not taken, assign the user who commented + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + assignees: [commenter] + }); + + // Post a comment to confirm assignment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body: `๐Ÿ‘‹ @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).` + }); + + - name: 'Unassign issue from user' + if: "contains(github.event.comment.body, '/unassign')" + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const issueNumber = context.issue.number; + const commenter = context.actor; + const owner = context.repo.owner; + const repo = context.repo.repo; + const commentBody = context.payload.comment.body.trim(); + + if (commentBody !== '/unassign') { + return; + } + + const issue = await github.rest.issues.get({ + owner: owner, + repo: repo, + issue_number: issueNumber, + }); + + const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter); + + if (isAssigned) { + await github.rest.issues.removeAssignees({ + owner: owner, + repo: repo, + issue_number: issueNumber, + assignees: [commenter] + }); + await github.rest.issues.createComment({ + owner: owner, + repo: repo, + issue_number: issueNumber, + body: `๐Ÿ‘‹ @${commenter}, you have been unassigned from this issue.` + }); + } diff --git a/.github/workflows/issue-opened-labeler.yml b/.github/workflows/issue-opened-labeler.yml new file mode 100644 index 0000000000000000000000000000000000000000..69a0911954c4df64caa5597f9bb9867cf9588223 --- /dev/null +++ b/.github/workflows/issue-opened-labeler.yml @@ -0,0 +1,46 @@ +name: '๐Ÿท๏ธ Issue Opened Labeler' + +on: + issues: + types: + - 'opened' + +jobs: + label-issue: + runs-on: 'ubuntu-latest' + if: |- + ${{ github.repository == 'google-gemini/gemini-cli' || github.repository == 'google-gemini/maintainers-gemini-cli' }} + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + env: + APP_ID: '${{ secrets.APP_ID }}' + if: |- + ${{ env.APP_ID != '' }} + uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Add need-triage label' + uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' + with: + github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}' + script: |- + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const hasLabel = issue.labels.some(l => l.name === 'status/need-triage'); + if (!hasLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['status/need-triage'] + }); + } else { + core.info('Issue already has status/need-triage label. Skipping.'); + } diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml new file mode 100644 index 0000000000000000000000000000000000000000..920fc1e4c367430f5dd07c9d004d2fd939ffff26 --- /dev/null +++ b/.github/workflows/label-backlog-child-issues.yml @@ -0,0 +1,61 @@ +name: 'Label Child Issues for Project Rollup' + +on: + issues: + types: ['opened', 'edited', 'reopened'] + schedule: + - cron: '0 * * * *' # Run every hour + workflow_dispatch: + +permissions: + issues: 'write' + contents: 'read' + +jobs: + # Event-based: Quick reaction to new/edited issues in THIS repo + labeler: + if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'" + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 + with: + persist-credentials: false + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Run Multi-Repo Sync Script' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node .github/scripts/sync-maintainer-labels.cjs' + + # Scheduled/Manual: Recursive sync across multiple repos + sync-maintainer-labels: + if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')" + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 + with: + persist-credentials: false + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Run Multi-Repo Sync Script' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node .github/scripts/sync-maintainer-labels.cjs' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml new file mode 100644 index 0000000000000000000000000000000000000000..9a44a9c25da3db2dee6e0816c3dc9f184bd64910 --- /dev/null +++ b/.github/workflows/label-workstream-rollup.yml @@ -0,0 +1,175 @@ +name: 'Label Workstream Rollup' + +on: + issues: + types: ['opened', 'edited', 'reopened'] + schedule: + - cron: '0 * * * *' + workflow_dispatch: + +jobs: + labeler: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + permissions: + issues: 'write' + steps: + - name: 'Check for Parent Workstream and Apply Label' + uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7 + with: + script: | + const labelToAdd = 'workstream-rollup'; + + // Allow-list of parent issue URLs + const allowedParentUrls = [ + 'https://github.com/google-gemini/gemini-cli/issues/15374', + 'https://github.com/google-gemini/gemini-cli/issues/15456', + 'https://github.com/google-gemini/gemini-cli/issues/15324', + 'https://github.com/google-gemini/gemini-cli/issues/17202', + 'https://github.com/google-gemini/gemini-cli/issues/17203' + ]; + + // Single issue processing (for event triggers) + async function processSingleIssue(owner, repo, number) { + const query = ` + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + issue(number:$number) { + number + parent { + url + parent { + url + parent { + url + parent { + url + parent { + url + } + } + } + } + } + } + } + } + `; + try { + const result = await github.graphql(query, { owner, repo, number }); + + if (!result || !result.repository || !result.repository.issue) { + console.log(`Issue #${number} not found or data missing.`); + return; + } + + const issue = result.repository.issue; + await checkAndLabel(issue, owner, repo); + } catch (error) { + console.error(`Failed to process issue #${number}:`, error); + throw error; // Re-throw to be caught by main execution + } + } + + // Bulk processing (for schedule/dispatch) + async function processAllOpenIssues(owner, repo) { + const query = ` + query($owner:String!, $repo:String!, $cursor:String) { + repository(owner:$owner, name:$repo) { + issues(first: 100, states: OPEN, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + parent { + url + parent { + url + parent { + url + parent { + url + parent { + url + } + } + } + } + } + } + } + } + } + `; + + let hasNextPage = true; + let cursor = null; + + while (hasNextPage) { + try { + const result = await github.graphql(query, { owner, repo, cursor }); + + if (!result || !result.repository || !result.repository.issues) { + console.error('Invalid response structure from GitHub API'); + break; + } + + const issues = result.repository.issues.nodes || []; + + console.log(`Processing batch of ${issues.length} issues...`); + for (const issue of issues) { + await checkAndLabel(issue, owner, repo); + } + + hasNextPage = result.repository.issues.pageInfo.hasNextPage; + cursor = result.repository.issues.pageInfo.endCursor; + } catch (error) { + console.error('Failed to fetch issues batch:', error); + throw error; // Re-throw to be caught by main execution + } + } + } + + async function checkAndLabel(issue, owner, repo) { + if (!issue || !issue.parent) return; + + let currentParent = issue.parent; + let tracedParents = []; + let matched = false; + + while (currentParent) { + tracedParents.push(currentParent.url); + + if (allowedParentUrls.includes(currentParent.url)) { + console.log(`SUCCESS: Issue #${issue.number} is a descendant of ${currentParent.url}. Trace: ${tracedParents.join(' -> ')}. Adding label.`); + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issue.number, + labels: [labelToAdd] + }); + matched = true; + break; + } + currentParent = currentParent.parent; + } + + if (!matched && context.eventName === 'issues') { + console.log(`Issue #${issue.number} did not match any allowed workstreams. Trace: ${tracedParents.join(' -> ') || 'None'}.`); + } + } + + // Main execution + try { + if (context.eventName === 'issues') { + console.log(`Processing single issue #${context.payload.issue.number}...`); + await processSingleIssue(context.repo.owner, context.repo.repo, context.payload.issue.number); + } else { + console.log(`Running for event: ${context.eventName}. Processing all open issues...`); + await processAllOpenIssues(context.repo.owner, context.repo.repo); + } + } catch (error) { + core.setFailed(`Workflow failed: ${error.message}`); + } diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 0000000000000000000000000000000000000000..cbc5bb4f0401db481899ac742a663fdb7a019058 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,27 @@ +name: 'Links' + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + repository_dispatch: + workflow_dispatch: + schedule: + - cron: '00 18 * * *' + +jobs: + linkChecker: + if: |- + ${{ github.repository == 'google-gemini/gemini-cli' }} + runs-on: 'ubuntu-latest' + steps: + - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Link Checker' + id: 'lychee' + uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1 + with: + args: '--verbose --no-progress --accept 200,503 ./**/*.md' diff --git a/.github/workflows/memory-nightly.yml b/.github/workflows/memory-nightly.yml new file mode 100644 index 0000000000000000000000000000000000000000..5a953999db032d52207e25c6dd350c3cbad1481f --- /dev/null +++ b/.github/workflows/memory-nightly.yml @@ -0,0 +1,35 @@ +name: 'Memory Tests: Nightly' + +on: + schedule: + - cron: '0 2 * * *' # Runs at 2 AM every day + workflow_dispatch: # Allow manual trigger + +permissions: + contents: 'read' + +jobs: + memory-test: + name: 'Run Memory Usage Tests' + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Run Memory Tests' + run: 'npm run test:memory' diff --git a/.github/workflows/perf-nightly.yml b/.github/workflows/perf-nightly.yml new file mode 100644 index 0000000000000000000000000000000000000000..f45ab487e2deb3a07f758ea1a2894d262d0c503a --- /dev/null +++ b/.github/workflows/perf-nightly.yml @@ -0,0 +1,35 @@ +name: 'Performance Tests: Nightly' + +on: + schedule: + - cron: '0 3 * * *' # Runs at 3 AM every day + workflow_dispatch: # Allow manual trigger + +permissions: + contents: 'read' + +jobs: + perf-test: + name: 'Run Performance Usage Tests' + runs-on: 'gemini-cli-ubuntu-16-core' + if: "github.repository == 'google-gemini/gemini-cli'" + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + persist-credentials: false + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Build project' + run: 'npm run build' + + - name: 'Run Performance Tests' + run: 'npm run test:perf' diff --git a/.github/workflows/pr-rate-limiter.yaml b/.github/workflows/pr-rate-limiter.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c703279532ce002a09fd567313385ad339681865 --- /dev/null +++ b/.github/workflows/pr-rate-limiter.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: 'PR rate limiter' + +permissions: {} + +on: + pull_request_target: + types: + - 'opened' + - 'reopened' + +jobs: + limit: + runs-on: 'gemini-cli-ubuntu-16-core' + permissions: + contents: 'read' + pull-requests: 'write' + steps: + - name: 'Limit open pull requests per user' + uses: 'Homebrew/actions/limit-pull-requests@9ceb7934560eb61d131dde205a6c2d77b2e1529d' # master + with: + except-author-associations: 'MEMBER,OWNER,COLLABORATOR' + comment-limit: 8 + comment: > + You already have 7 pull requests open. Please work on getting + existing PRs merged before opening more. + close-limit: 8 + close: true diff --git a/.github/workflows/pr-size-labeler-batch-run.yml b/.github/workflows/pr-size-labeler-batch-run.yml new file mode 100644 index 0000000000000000000000000000000000000000..1b010dba13bd0433c0393cb7fe0d575bcdd75b72 --- /dev/null +++ b/.github/workflows/pr-size-labeler-batch-run.yml @@ -0,0 +1,107 @@ +name: 'PR Size Labeler (Batch)' + +on: + workflow_dispatch: + inputs: + process_all: + description: 'Process all PRs (open and closed) or open only' + required: true + default: 'false' + type: 'choice' + options: + - 'true' + - 'false' + limit: + description: 'Max number of PRs to fetch and check' + required: true + default: '100' + type: 'string' + +permissions: + pull-requests: 'write' + +jobs: + batch-label: + runs-on: 'ubuntu-latest' + steps: + - name: 'Batch label PRs' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GH_REPO: '${{ github.repository }}' + run: | + # Determine the state filter + STATE="open" + if [ "${{ github.event.inputs.process_all }}" = "true" ]; then + STATE="all" + fi + + LIMIT="${{ github.event.inputs.limit }}" + echo "Batch labeling up to $LIMIT $STATE PRs..." + + # 1. Ensure standard premium size labels exist in the repository (self-healing) + gh label create "size/XS" --color "7ee081" --description "XS: <10 lines changed" 2>/dev/null || true + gh label create "size/S" --color "a6d49f" --description "S: 10-49 lines changed" 2>/dev/null || true + gh label create "size/M" --color "f7d070" --description "M: 50-249 lines changed" 2>/dev/null || true + gh label create "size/L" --color "f48c06" --description "L: 250-999 lines changed" 2>/dev/null || true + gh label create "size/XL" --color "dc2f02" --description "XL: >=1000 lines changed" 2>/dev/null || true + + # 2. Query PR list with all required fields in ONE call to prevent N+1 queries + PR_LIST=$(gh pr list --state "$STATE" --limit "$LIMIT" --json number,additions,deletions,labels) + if [ -z "$PR_LIST" ] || [ "$PR_LIST" = "[]" ]; then + echo "โ„น๏ธ No PRs found matching the criteria." + exit 0 + fi + + # Parse and iterate over PRs + UPDATED_COUNT=0 + SKIPPED_COUNT=0 + + echo "$PR_LIST" | jq -c '.[]' | while read -r PR_JSON; do + PR_NUMBER=$(echo "$PR_JSON" | jq '.number') + ADDITIONS=$(echo "$PR_JSON" | jq '.additions') + DELETIONS=$(echo "$PR_JSON" | jq '.deletions') + TOTAL=$((ADDITIONS + DELETIONS)) + + # Calculate target size + if [ $TOTAL -lt 10 ]; then + SIZE="size/XS" + elif [ $TOTAL -lt 50 ]; then + SIZE="size/S" + elif [ $TOTAL -lt 250 ]; then + SIZE="size/M" + elif [ $TOTAL -lt 1000 ]; then + SIZE="size/L" + else + SIZE="size/XL" + fi + + # Inspect existing labels to detect discrepancies + EXISTING_LABELS=$(echo "$PR_JSON" | jq -r '.labels[].name' 2>/dev/null || echo "") + + LABELS_TO_REMOVE=() + for L in size/XS size/S size/M size/L size/XL; do + if echo "$EXISTING_LABELS" | grep -Fq "$L" && [ "$L" != "$SIZE" ]; then + LABELS_TO_REMOVE+=("--remove-label" "$L") + fi + done + + LABEL_TO_ADD=() + if ! echo "$EXISTING_LABELS" | grep -Fq "$SIZE"; then + LABEL_TO_ADD+=("--add-label" "$SIZE") + fi + + # Update labels if there's a difference + if [ ${#LABELS_TO_REMOVE[@]} -gt 0 ] || [ ${#LABEL_TO_ADD[@]} -gt 0 ]; then + echo "๐Ÿ”„ PR #$PR_NUMBER (+$ADDITIONS/-$DELETIONS = $TOTAL lines): updating size to $SIZE" + gh pr edit "$PR_NUMBER" "${LABELS_TO_REMOVE[@]}" "${LABEL_TO_ADD[@]}" 2>/dev/null || true + UPDATED_COUNT=$((UPDATED_COUNT + 1)) + else + echo "โœ… PR #$PR_NUMBER (+$ADDITIONS/-$DELETIONS = $TOTAL lines): already has correct label ($SIZE). Skipping." + SKIPPED_COUNT=$((SKIPPED_COUNT + 1)) + fi + done + + echo "============================================" + echo "๐ŸŽ‰ Batch run completed!" + echo "Skipped (already correct): $SKIPPED_COUNT" + echo "Updated: $UPDATED_COUNT" diff --git a/.github/workflows/pr-size-labeler.yml b/.github/workflows/pr-size-labeler.yml new file mode 100644 index 0000000000000000000000000000000000000000..d53f125e6c72ff7c459b8138fb624aa2461456a9 --- /dev/null +++ b/.github/workflows/pr-size-labeler.yml @@ -0,0 +1,120 @@ +name: 'PR Size Labeler' + +on: + pull_request_target: + types: ['opened', 'synchronize', 'reopened'] + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to label manually (for workflow_dispatch)' + required: false + type: 'string' + +permissions: + pull-requests: 'write' + issues: 'write' + +jobs: + size-label: + runs-on: 'ubuntu-latest' + steps: + - name: 'Run size labeler' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GH_REPO: '${{ github.repository }}' + run: | + # Determine the target PR number + if [ -n "${{ github.event.pull_request.number }}" ]; then + PR_NUMBER="${{ github.event.pull_request.number }}" + elif [ -n "${{ github.event.inputs.pr_number }}" ]; then + PR_NUMBER="${{ github.event.inputs.pr_number }}" + else + echo "โŒ Error: No PR number provided." + exit 1 + fi + + echo "Checking PR #$PR_NUMBER..." + + # 1. Ensure standard premium size labels exist in the repository (self-healing) + # size/XS: Light green (#7ee081) + # size/S: Yellow-green (#a6d49f) + # size/M: Amber/Yellow (#f7d070) + # size/L: Orange (#f48c06) + # size/XL: Red (#dc2f02) + gh label create "size/XS" --color "7ee081" --description "XS: <10 lines changed" 2>/dev/null || true + gh label create "size/S" --color "a6d49f" --description "S: 10-49 lines changed" 2>/dev/null || true + gh label create "size/M" --color "f7d070" --description "M: 50-249 lines changed" 2>/dev/null || true + gh label create "size/L" --color "f48c06" --description "L: 250-999 lines changed" 2>/dev/null || true + gh label create "size/XL" --color "dc2f02" --description "XL: >=1000 lines changed" 2>/dev/null || true + + # 2. Fetch PR details in a single efficient API call + PR_DATA=$(gh pr view "$PR_NUMBER" --json additions,deletions,changedFiles,labels) + if [ -z "$PR_DATA" ]; then + echo "โŒ Error: Could not fetch PR details." + exit 1 + fi + + ADDITIONS=$(echo "$PR_DATA" | jq '.additions') + DELETIONS=$(echo "$PR_DATA" | jq '.deletions') + CHANGED_FILES=$(echo "$PR_DATA" | jq '.changedFiles') + TOTAL=$((ADDITIONS + DELETIONS)) + + echo "PR additions: $ADDITIONS, deletions: $DELETIONS, total changes: $TOTAL, files: $CHANGED_FILES" + + # 3. Calculate new size label + if [ $TOTAL -lt 10 ]; then + SIZE="size/XS" + elif [ $TOTAL -lt 50 ]; then + SIZE="size/S" + elif [ $TOTAL -lt 250 ]; then + SIZE="size/M" + elif [ $TOTAL -lt 1000 ]; then + SIZE="size/L" + else + SIZE="size/XL" + fi + + # 4. Check existing labels and update only if necessary + EXISTING_LABELS=$(echo "$PR_DATA" | jq -r '.labels[].name' 2>/dev/null || echo "") + + LABELS_TO_REMOVE=() + for L in size/XS size/S size/M size/L size/XL; do + if echo "$EXISTING_LABELS" | grep -Fq "$L" && [ "$L" != "$SIZE" ]; then + LABELS_TO_REMOVE+=("--remove-label" "$L") + fi + done + + LABEL_TO_ADD=() + if ! echo "$EXISTING_LABELS" | grep -Fq "$SIZE"; then + LABEL_TO_ADD+=("--add-label" "$SIZE") + fi + + # Perform a single, highly atomic edit call if changes are needed + if [ ${#LABELS_TO_REMOVE[@]} -gt 0 ] || [ ${#LABEL_TO_ADD[@]} -gt 0 ]; then + echo "Updating labels: removing ${LABELS_TO_REMOVE[*]}, adding $SIZE" + gh pr edit "$PR_NUMBER" "${LABELS_TO_REMOVE[@]}" "${LABEL_TO_ADD[@]}" + else + echo "โœ… PR #$PR_NUMBER already has the correct size label ($SIZE)." + fi + + # 5. Premium, anti-spam comment logic (updates previous comment to keep thread clean) + COMMENT="๐Ÿ“Š PR Size: **$SIZE** + - Lines changed: **$TOTAL** + - Additions: +$ADDITIONS + - Deletions: -$DELETIONS + - Files changed: $CHANGED_FILES" + + # Find any existing size labeler comment by the github-actions bot + echo "Searching for existing size comment..." + COMMENT_ID=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("๐Ÿ“Š PR Size:"))) | .id' | head -n 1) + + if [ -n "$COMMENT_ID" ]; then + echo "Updating existing comment (ID: $COMMENT_ID)..." + gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -X PATCH -f body="$COMMENT" > /dev/null + else + echo "Creating new comment..." + gh pr comment "$PR_NUMBER" --body "$COMMENT" > /dev/null + fi + + echo "๐ŸŽ‰ PR size labeling completed successfully." diff --git a/.github/workflows/release-change-tags.yml b/.github/workflows/release-change-tags.yml new file mode 100644 index 0000000000000000000000000000000000000000..09515f27d4a0cfc66fb24a39320795d16ca29c03 --- /dev/null +++ b/.github/workflows/release-change-tags.yml @@ -0,0 +1,67 @@ +name: 'Release: Change Tags' + +on: + workflow_dispatch: + inputs: + version: + description: 'The package version to tag (e.g., 0.5.0-preview-2). This version must already exist on the npm registry.' + required: true + type: 'string' + channel: + description: 'The npm dist-tag to apply (e.g., latest, preview, nightly).' + required: true + type: 'choice' + options: + - 'dev' + - 'latest' + - 'preview' + - 'nightly' + dry-run: + description: 'Whether to run in dry-run mode.' + required: false + type: 'boolean' + default: true + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + packages: 'write' + issues: 'write' + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 + with: + ref: '${{ github.ref }}' + fetch-depth: 0 + persist-credentials: false + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + + - name: 'Change tag' + uses: './.github/actions/tag-npm-release' + with: + channel: '${{ github.event.inputs.channel }}' + version: '${{ github.event.inputs.version }}' + dry-run: '${{ github.event.inputs.dry-run }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' + core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' + a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' + working-directory: '.' diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml new file mode 100644 index 0000000000000000000000000000000000000000..2a19aa1139a364608db3338e9d8444646e19fbfd --- /dev/null +++ b/.github/workflows/release-manual.yml @@ -0,0 +1,151 @@ +name: 'Release: Manual' + +on: + workflow_dispatch: + inputs: + version: + description: 'The version to release (e.g., v0.1.11). Must be a valid semver string with a "v" prefix.' + required: true + type: 'string' + ref: + description: 'The branch, tag, or SHA to release from.' + required: true + type: 'string' + npm_channel: + description: 'The npm channel to publish to' + required: true + type: 'choice' + options: + - 'dev' + - 'preview' + - 'nightly' + - 'latest' + default: 'latest' + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + skip_github_release: + description: 'Select to skip creating a GitHub release (only used when environment is PROD)' + required: false + type: 'boolean' + default: false + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + build-mac: + if: "github.repository == 'google-gemini/gemini-cli'" + uses: './.github/workflows/build-unsigned-mac-binaries.yml' + with: + ref: '${{ github.event.inputs.ref }}' + + release: + if: "github.repository == 'google-gemini/gemini-cli'" + needs: ['build-mac'] + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + contents: 'write' + packages: 'write' + issues: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + fetch-depth: 0 + + - name: 'Checkout Release Code' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref }}' + path: 'release' + fetch-depth: 0 + + - name: 'Debug Inputs' + shell: 'bash' + env: + JSON_INPUTS: '${{ toJSON(inputs) }}' + run: 'echo "$JSON_INPUTS"' + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: './release/.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + working-directory: './release' + run: 'npm ci' + + - name: 'Download macOS Binaries' + uses: './.github/actions/download-mac-binaries' + with: + path: 'release/dist' + + - name: 'Prepare Release Info' + id: 'release_info' + working-directory: './release' + env: + INPUT_VERSION: '${{ github.event.inputs.version }}' + run: | + RELEASE_VERSION="${INPUT_VERSION}" + echo "RELEASE_VERSION=${RELEASE_VERSION#v}" >> "${GITHUB_OUTPUT}" + echo "PREVIOUS_TAG=$(git describe --tags --abbrev=0)" >> "${GITHUB_OUTPUT}" + + - name: 'Run Tests' + if: "${{github.event.inputs.force_skip_tests != 'true'}}" + uses: './.github/actions/run-tests' + with: + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + working-directory: './release' + + - name: 'Publish Release' + uses: './.github/actions/publish-release' + with: + force-skip-tests: '${{ github.event.inputs.force_skip_tests }}' + release-version: '${{ steps.release_info.outputs.RELEASE_VERSION }}' + release-tag: '${{ github.event.inputs.version }}' + npm-tag: '${{ github.event.inputs.npm_channel }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + dry-run: '${{ github.event.inputs.dry_run }}' + previous-tag: '${{ steps.release_info.outputs.PREVIOUS_TAG }}' + skip-github-release: '${{ github.event.inputs.skip_github_release }}' + working-directory: './release' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' + npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' + npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' + cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' + core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' + a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' + + - name: 'Create Issue on Failure' + if: '${{ failure() && github.event.inputs.dry_run == false }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: '${{ github.event.inputs.version }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + gh issue create \ + --title 'Manual Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ + --body 'The manual release workflow failed. See the full run for details: ${DETAILS_URL}' \ + --label 'release-failure,priority/p0' diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml new file mode 100644 index 0000000000000000000000000000000000000000..226f9eb29c7bd7b8170bac180f8247ba3fe455d6 --- /dev/null +++ b/.github/workflows/release-nightly.yml @@ -0,0 +1,176 @@ +name: 'Release: Nightly' + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: true + ref: + description: 'The branch, tag, or SHA to release from.' + required: false + type: 'string' + default: 'main' + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + build-mac: + if: "github.repository == 'google-gemini/gemini-cli'" + uses: './.github/workflows/build-unsigned-mac-binaries.yml' + with: + ref: '${{ github.event.inputs.ref }}' + + release: + if: "github.repository == 'google-gemini/gemini-cli'" + needs: ['build-mac'] + environment: "${{ github.event_name == 'schedule' && 'internal' || github.event.inputs.environment || 'prod' }}" + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + packages: 'write' + issues: 'write' + pull-requests: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + fetch-depth: 0 + + - name: 'Checkout Release Code' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref }}' + path: 'release' + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: './release/.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + working-directory: './release' + run: 'npm ci' + + - name: 'Download macOS Binaries' + uses: './.github/actions/download-mac-binaries' + with: + path: 'release/dist' + + - name: 'Print Inputs' + shell: 'bash' + env: + JSON_INPUTS: '${{ toJSON(github.event.inputs) }}' + run: 'echo "$JSON_INPUTS"' + + - name: 'Calculate Release Variables' + id: 'vars' + uses: './.github/actions/calculate-vars' + with: + dry_run: '${{ github.event.inputs.dry_run }}' + + - name: 'Print Calculated vars' + shell: 'bash' + env: + JSON_VARS: '${{ toJSON(steps.vars.outputs) }}' + run: 'echo "$JSON_VARS"' + + - name: 'Run Tests' + if: "${{ github.event_name == 'schedule' || github.event.inputs.force_skip_tests == 'false' }}" + uses: './.github/actions/run-tests' + with: + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + working-directory: './release' + + - name: 'Get Nightly Version' + id: 'nightly_version' + working-directory: './release' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + # Calculate the version using the centralized script + VERSION_JSON=$(node scripts/get-release-version.js --type=nightly) + + # Extract values for logging and outputs + RELEASE_TAG=$(echo "${VERSION_JSON}" | jq -r .releaseTag) + RELEASE_VERSION=$(echo "${VERSION_JSON}" | jq -r .releaseVersion) + NPM_TAG=$(echo "${VERSION_JSON}" | jq -r .npmTag) + PREVIOUS_TAG=$(echo "${VERSION_JSON}" | jq -r .previousReleaseTag) + + # Print calculated values for logging + echo "Calculated Release Tag: ${RELEASE_TAG}" + echo "Calculated Release Version: ${RELEASE_VERSION}" + echo "Calculated Previous Tag: ${PREVIOUS_TAG}" + + # Set outputs for subsequent steps + echo "RELEASE_TAG=${RELEASE_TAG}" >> "${GITHUB_OUTPUT}" + echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_OUTPUT}" + echo "NPM_TAG=${NPM_TAG}" >> "${GITHUB_OUTPUT}" + echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "${GITHUB_OUTPUT}" + + - name: 'Publish Release' + if: true + uses: './.github/actions/publish-release' + with: + release-version: '${{ steps.nightly_version.outputs.RELEASE_VERSION }}' + release-tag: '${{ steps.nightly_version.outputs.RELEASE_TAG }}' + npm-tag: '${{ steps.nightly_version.outputs.NPM_TAG }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + dry-run: '${{ steps.vars.outputs.is_dry_run }}' + previous-tag: '${{ steps.nightly_version.outputs.PREVIOUS_TAG }}' + working-directory: './release' + skip-branch-cleanup: true + force-skip-tests: "${{ github.event_name != 'schedule' && github.event.inputs.force_skip_tests == 'true' }}" + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + npm-registry-publish-url: "${{ vars.NPM_REGISTRY_PUBLISH_URL || 'https://wombat-dressing-room.appspot.com' }}" + npm-registry-url: "${{ vars.NPM_REGISTRY_URL || 'https://wombat-dressing-room.appspot.com' }}" + npm-registry-scope: "${{ vars.NPM_REGISTRY_SCOPE || '@google' }}" + cli-package-name: "${{ vars.CLI_PACKAGE_NAME || '@google/gemini-cli' }}" + core-package-name: "${{ vars.CORE_PACKAGE_NAME || '@google/gemini-cli-core' }}" + a2a-package-name: "${{ vars.A2A_PACKAGE_NAME || '@google/gemini-cli-a2a-server' }}" + + - name: 'Create and Merge Pull Request' + if: "github.event.inputs.environment != 'dev'" + uses: './.github/actions/create-pull-request' + with: + branch-name: 'release/${{ steps.nightly_version.outputs.RELEASE_TAG }}' + pr-title: 'chore/release: bump version to ${{ steps.nightly_version.outputs.RELEASE_VERSION }}' + pr-body: 'Automated version bump for nightly release.' + github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + dry-run: '${{ steps.vars.outputs.is_dry_run }}' + working-directory: './release' + + - name: 'Create Issue on Failure' + if: "${{ failure() && github.event.inputs.environment != 'dev' && (github.event_name == 'schedule' || github.event.inputs.dry_run != 'true') }}" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: '${{ steps.nightly_version.outputs.RELEASE_TAG }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + gh issue create \ + --title "Nightly Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ + --body "The nightly-release workflow failed. See the full run for details: ${DETAILS_URL}" \ + --label 'release-failure,priority/p0' diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml new file mode 100644 index 0000000000000000000000000000000000000000..6ee76fd0648dbbe6f4a444acaf9c14804f914795 --- /dev/null +++ b/.github/workflows/release-notes.yml @@ -0,0 +1,107 @@ +# This workflow is triggered on every new release. +# It uses Gemini to generate release notes and creates a PR with the changes. +name: 'Generate Release Notes' + +on: + release: + types: ['published'] + workflow_dispatch: + inputs: + version: + description: 'New version (e.g., v1.2.3)' + required: true + type: 'string' + body: + description: 'Release notes body' + required: true + type: 'string' + time: + description: 'Release time' + required: true + type: 'string' + +jobs: + generate-release-notes: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + pull-requests: 'write' + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 + with: + persist-credentials: false + # The user-level skills need to be available to the workflow + fetch-depth: 0 + ref: 'main' + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + + - name: 'Get release information' + id: 'release_info' + run: | + VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}" + TIME="${{ github.event.inputs.time || github.event.release.created_at }}" + + echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" + echo "TIME=${TIME}" >> "$GITHUB_OUTPUT" + + # Use a heredoc to preserve multiline release body + echo 'RAW_CHANGELOG<> "$GITHUB_OUTPUT" + printf "%s\n" "$BODY" >> "$GITHUB_OUTPUT" + echo 'EOF' >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + BODY: '${{ github.event.inputs.body || github.event.release.body }}' + + - name: 'Validate version' + id: 'validate_version' + run: | + if echo "${{ steps.release_info.outputs.VERSION }}" | grep -q "nightly"; then + echo "Nightly release detected. Stopping workflow." + echo "CONTINUE=false" >> "$GITHUB_OUTPUT" + else + echo "CONTINUE=true" >> "$GITHUB_OUTPUT" + fi + + - name: 'Generate Changelog with Gemini' + if: "steps.validate_version.outputs.CONTINUE == 'true'" + uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 + env: + GEMINI_CLI_TRUST_WORKSPACE: true + with: + upload_artifacts: 'true' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + prompt: | + Activate the 'docs-changelog' skill. + + **Release Information:** + - New Version: ${{ steps.release_info.outputs.VERSION }} + - Release Date: ${{ steps.release_info.outputs.TIME }} + - Raw Changelog Data: ${{ steps.release_info.outputs.RAW_CHANGELOG }} + + Execute the release notes generation process using the information provided. + + When you are done, please output your thought process and the steps you took for future debugging purposes. + + - name: 'Create Pull Request' + if: "steps.validate_version.outputs.CONTINUE == 'true'" + uses: 'peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c' # ratchet:peter-evans/create-pull-request@v6 + with: + token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}' + title: 'Changelog for ${{ steps.release_info.outputs.VERSION }}' + body: | + This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release. + + Please review and merge. + + Related to #18505 + branch: 'changelog-${{ steps.release_info.outputs.VERSION }}' + base: 'main' + team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers' + delete-branch: true diff --git a/.github/workflows/release-patch-0-from-comment.yml b/.github/workflows/release-patch-0-from-comment.yml new file mode 100644 index 0000000000000000000000000000000000000000..29a05884add84988c462dd5da88efc63bd7429e6 --- /dev/null +++ b/.github/workflows/release-patch-0-from-comment.yml @@ -0,0 +1,197 @@ +name: 'Release: Patch (0) from Comment' + +on: + issue_comment: + types: ['created'] + +jobs: + slash-command: + runs-on: 'ubuntu-latest' + # Only run if the comment is from a human user (not automated) + if: "github.event.comment.user.type == 'User' && github.event.comment.user.login != 'github-actions[bot]'" + permissions: + contents: 'write' + pull-requests: 'write' + actions: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + fetch-depth: 1 + + - name: 'Slash Command Dispatch' + id: 'slash_command' + uses: 'peter-evans/slash-command-dispatch@40877f718dce0101edfc7aea2b3800cc192f9ed5' + with: + token: '${{ secrets.GITHUB_TOKEN }}' + commands: 'patch' + permission: 'write' + issue-type: 'pull-request' + + - name: 'Get PR Status' + id: 'pr_status' + if: "startsWith(github.event.comment.body, '/patch')" + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + gh pr view "${{ github.event.issue.number }}" --json mergeCommit,state > pr_status.json + echo "MERGE_COMMIT_SHA=$(jq -r .mergeCommit.oid pr_status.json)" >> "$GITHUB_OUTPUT" + echo "STATE=$(jq -r .state pr_status.json)" >> "$GITHUB_OUTPUT" + + - name: 'Dispatch if Merged' + if: "steps.pr_status.outputs.STATE == 'MERGED'" + id: 'dispatch_patch' + uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325' + env: + COMMENT_BODY: '${{ github.event.comment.body }}' + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + script: | + // Parse the comment body directly to extract channel(s) + const commentBody = process.env.COMMENT_BODY; + console.log('Comment body:', commentBody); + + let channels = ['stable', 'preview']; // default to both + + // Parse different formats: + // /patch (defaults to both) + // /patch both + // /patch stable + // /patch preview + if (commentBody.trim() === '/patch' || commentBody.trim() === '/patch both') { + channels = ['stable', 'preview']; + } else if (commentBody.trim() === '/patch stable') { + channels = ['stable']; + } else if (commentBody.trim() === '/patch preview') { + channels = ['preview']; + } else { + // Fallback parsing for legacy formats + if (commentBody.includes('channel=preview')) { + channels = ['preview']; + } else if (commentBody.includes('--channel preview')) { + channels = ['preview']; + } + } + + console.log('Detected channels:', channels); + + const dispatchedRuns = []; + + // Dispatch workflow for each channel + for (const channel of channels) { + console.log(`Dispatching workflow for channel: ${channel}`); + + const response = await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-patch-1-create-pr.yml', + ref: 'main', + inputs: { + commit: '${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}', + channel: channel, + original_pr: '${{ github.event.issue.number }}', + environment: 'prod' + } + }); + + dispatchedRuns.push({ channel, response }); + } + + // Wait a moment for the workflows to be created + await new Promise(resolve => setTimeout(resolve, 3000)); + + const runs = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release-patch-1-create-pr.yml', + per_page: 20 // Increased to handle multiple runs + }); + + // Find the recent runs that match our trigger + const recentRuns = runs.data.workflow_runs.filter(run => + run.event === 'workflow_dispatch' && + new Date(run.created_at) > new Date(Date.now() - 15000) // Within last 15 seconds + ).slice(0, channels.length); // Limit to the number of channels we dispatched + + // Set outputs + core.setOutput('dispatched_channels', channels.join(',')); + core.setOutput('dispatched_run_count', channels.length.toString()); + + if (recentRuns.length > 0) { + core.setOutput('dispatched_run_urls', recentRuns.map(r => r.html_url).join(',')); + core.setOutput('dispatched_run_ids', recentRuns.map(r => r.id).join(',')); + + const markdownLinks = recentRuns.map(r => `- [View dispatched workflow run](${r.html_url})`).join('\n'); + core.setOutput('dispatched_run_links', markdownLinks); + } + + - name: 'Comment on Failure' + if: "startsWith(github.event.comment.body, '/patch') && steps.pr_status.outputs.STATE != 'MERGED'" + uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' + with: + token: '${{ secrets.GITHUB_TOKEN }}' + issue-number: '${{ github.event.issue.number }}' + body: | + :x: The `/patch` command failed. This pull request must be merged before a patch can be created. + + - name: 'Final Status Comment - Success' + if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && steps.dispatch_patch.outputs.dispatched_run_urls" + uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' + with: + token: '${{ secrets.GITHUB_TOKEN }}' + issue-number: '${{ github.event.issue.number }}' + body: | + ๐Ÿš€ **[Step 1/4] Patch workflow(s) waiting for approval!** + + **๐Ÿ“‹ Details:** + - **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}` + - **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}` + - **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }} + + **โณ Status:** The patch creation workflow has been triggered and is waiting for deployment approval. Please visit the specific workflow links below and approve the runs. + + **๐Ÿ”— Track Progress:** + ${{ steps.dispatch_patch.outputs.dispatched_run_links }} + - [View patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) + - [This trigger workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + - name: 'Final Status Comment - Dispatch Success (No URL)' + if: "always() && startsWith(github.event.comment.body, '/patch') && steps.dispatch_patch.outcome == 'success' && !steps.dispatch_patch.outputs.dispatched_run_urls" + uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' + with: + token: '${{ secrets.GITHUB_TOKEN }}' + issue-number: '${{ github.event.issue.number }}' + body: | + ๐Ÿš€ **[Step 1/4] Patch workflow(s) waiting for approval!** + + **๐Ÿ“‹ Details:** + - **Channels**: `${{ steps.dispatch_patch.outputs.dispatched_channels }}` + - **Commit**: `${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}` + - **Workflows Created**: ${{ steps.dispatch_patch.outputs.dispatched_run_count }} + + **โณ Status:** The patch creation workflow has been triggered and is waiting for deployment approval. Please visit the workflow history link below and approve the runs. + + **๐Ÿ”— Track Progress:** + - [View patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) + - [This trigger workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + + - name: 'Final Status Comment - Failure' + if: "always() && startsWith(github.event.comment.body, '/patch') && (steps.dispatch_patch.outcome == 'failure' || steps.dispatch_patch.outcome == 'cancelled')" + uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' + with: + token: '${{ secrets.GITHUB_TOKEN }}' + issue-number: '${{ github.event.issue.number }}' + body: | + โŒ **[Step 1/4] Patch workflow dispatch failed!** + + There was an error dispatching the patch creation workflow. + + **๐Ÿ” Troubleshooting:** + - Check that the PR is properly merged + - Verify workflow permissions + - Review error logs in the workflow run + + **๐Ÿ”— Debug Links:** + - [This workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + - [Patch workflow history](https://github.com/${{ github.repository }}/actions/workflows/release-patch-1-create-pr.yml) diff --git a/.github/workflows/release-patch-1-create-pr.yml b/.github/workflows/release-patch-1-create-pr.yml new file mode 100644 index 0000000000000000000000000000000000000000..26b3eaeb6a99abb09d0b5223aba06aa831b7f6eb --- /dev/null +++ b/.github/workflows/release-patch-1-create-pr.yml @@ -0,0 +1,135 @@ +name: 'Release: Patch (1) Create PR' + +run-name: >- + Release Patch (1) Create PR | S:${{ inputs.channel }} | C:${{ inputs.commit }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }} + +on: + workflow_dispatch: + inputs: + commit: + description: 'The commit SHA to cherry-pick for the patch.' + required: true + type: 'string' + channel: + description: 'The release channel to patch.' + required: true + type: 'choice' + options: + - 'stable' + - 'preview' + dry_run: + description: 'Whether to run in dry-run mode.' + required: false + type: 'boolean' + default: false + ref: + description: 'The branch, tag, or SHA to test from.' + required: false + type: 'string' + default: 'main' + original_pr: + description: 'The original PR number to comment back on.' + required: false + type: 'string' + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + create-patch: + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + contents: 'write' + pull-requests: 'write' + actions: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + ref: '${{ github.event.inputs.ref }}' + fetch-depth: 0 + persist-credentials: false + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'configure .npmrc' + uses: './.github/actions/setup-npmrc' + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + + - name: 'Install Script Dependencies' + run: 'npm ci' + + - name: 'Configure Git User' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + REPOSITORY: '${{ github.repository }}' + run: |- + git config user.name "gemini-cli-robot" + git config user.email "gemini-cli-robot@google.com" + # Configure git to use GITHUB_TOKEN for remote operations (has actions:write for workflow files) + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPOSITORY}.git" + + - name: 'Create Patch' + id: 'create_patch' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' + PATCH_COMMIT: '${{ github.event.inputs.commit }}' + PATCH_CHANNEL: '${{ github.event.inputs.channel }}' + ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' + DRY_RUN: '${{ github.event.inputs.dry_run }}' + continue-on-error: true + run: | + # Capture output and display it in logs using tee + { + node scripts/releasing/create-patch-pr.js \ + --cli-package-name="${CLI_PACKAGE_NAME}" \ + --commit="${PATCH_COMMIT}" \ + --channel="${PATCH_CHANNEL}" \ + --pullRequestNumber="${ORIGINAL_PR}" \ + --dry-run="${DRY_RUN}" + } 2>&1 | tee >( + echo "LOG_CONTENT<> "$GITHUB_ENV" + cat >> "$GITHUB_ENV" + echo "EOF" >> "$GITHUB_ENV" + ) + echo "EXIT_CODE=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: 'Comment on Original PR' + if: 'always() && inputs.original_pr' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' + EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}' + COMMIT: '${{ github.event.inputs.commit }}' + CHANNEL: '${{ github.event.inputs.channel }}' + REPOSITORY: '${{ github.repository }}' + GITHUB_RUN_ID: '${{ github.run_id }}' + LOG_CONTENT: '${{ env.LOG_CONTENT }}' + TARGET_REF: '${{ github.event.inputs.ref }}' + ENVIRONMENT: '${{ github.event.inputs.environment }}' + continue-on-error: true + run: | + git checkout "${TARGET_REF}" + node scripts/releasing/patch-create-comment.js + + - name: 'Fail Workflow if Main Task Failed' + if: 'always() && steps.create_patch.outputs.EXIT_CODE != 0' + env: + EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}' + run: | + echo "Patch creation failed with exit code: ${EXIT_CODE}" + echo "Check the logs above and the comment posted to the original PR for details." + exit 1 diff --git a/.github/workflows/release-patch-2-trigger.yml b/.github/workflows/release-patch-2-trigger.yml new file mode 100644 index 0000000000000000000000000000000000000000..8505f198f196617fb602a31fd5a7a679c699cdd9 --- /dev/null +++ b/.github/workflows/release-patch-2-trigger.yml @@ -0,0 +1,95 @@ +name: 'Release: Patch (2) Trigger' + +run-name: >- + Release Patch (2) Trigger | + ${{ github.event.pull_request.number && format('PR #{0}', github.event.pull_request.number) || 'Manual' }} | + ${{ github.event.pull_request.head.ref || github.event.inputs.ref }} + +on: + pull_request: + types: + - 'closed' + branches: + - 'release/**' + workflow_dispatch: + inputs: + ref: + description: 'The head ref of the merged hotfix PR to trigger the release for (e.g. hotfix/v1.2.3/cherry-pick-abc).' + required: true + type: 'string' + workflow_ref: + description: 'The ref to checkout the workflow code from.' + required: false + type: 'string' + default: 'main' + workflow_id: + description: 'The workflow to trigger. Defaults to release-patch-3-release.yml' + required: false + type: 'string' + default: 'release-patch-3-release.yml' + dry_run: + description: 'Whether this is a dry run.' + required: false + type: 'boolean' + default: false + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + test_mode: + description: 'Whether or not to run in test mode' + required: false + type: 'boolean' + default: false + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + trigger-patch-release: + if: "(github.event_name == 'pull_request' && github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'hotfix/')) || github.event_name == 'workflow_dispatch'" + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + actions: 'write' + contents: 'write' + pull-requests: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: "${{ github.event.inputs.workflow_ref || 'main' }}" + fetch-depth: 1 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Trigger Patch Release' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + HEAD_REF: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.event.inputs.ref }}" + PR_BODY: "${{ github.event_name == 'pull_request' && github.event.pull_request.body || '' }}" + WORKFLOW_ID: '${{ github.event.inputs.workflow_id }}' + GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' + GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' + GITHUB_EVENT_NAME: '${{ github.event_name }}' + GITHUB_EVENT_PAYLOAD: '${{ toJSON(github.event) }}' + FORCE_SKIP_TESTS: '${{ github.event.inputs.force_skip_tests }}' + TEST_MODE: '${{ github.event.inputs.test_mode }}' + ENVIRONMENT: "${{ github.event.inputs.environment || 'prod' }}" + DRY_RUN: '${{ github.event.inputs.dry_run }}' + run: | + node scripts/releasing/patch-trigger.js --dry-run="${DRY_RUN}" diff --git a/.github/workflows/release-patch-3-release.yml b/.github/workflows/release-patch-3-release.yml new file mode 100644 index 0000000000000000000000000000000000000000..3dfb992a72c3bbabd0a56b2348051d23a293ff12 --- /dev/null +++ b/.github/workflows/release-patch-3-release.yml @@ -0,0 +1,260 @@ +name: 'Release: Patch (3) Release' + +run-name: >- + Release Patch (3) Release | T:${{ inputs.type }} | R:${{ inputs.release_ref }} ${{ inputs.original_pr && format('| PR:#{0}', inputs.original_pr) || '' }} + +on: + workflow_dispatch: + inputs: + type: + description: 'The type of release to perform.' + required: true + type: 'choice' + options: + - 'stable' + - 'preview' + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + release_ref: + description: 'The branch, tag, or SHA to release from.' + required: true + type: 'string' + original_pr: + description: 'The original PR number to comment back on.' + required: false + type: 'string' + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + release: + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + contents: 'write' + packages: 'write' + pull-requests: 'write' + issues: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + fetch-depth: 0 + fetch-tags: true + + - name: 'Checkout Release Code' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.release_ref }}' + path: 'release' + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'configure .npmrc' + uses: './.github/actions/setup-npmrc' + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + + - name: 'Install Script Dependencies' + run: |- + npm ci + + - name: 'Install Dependencies' + working-directory: './release' + run: |- + npm ci + + - name: 'Print Inputs' + shell: 'bash' + env: + JSON_INPUTS: '${{ toJSON(inputs) }}' + run: 'echo "$JSON_INPUTS"' + + - name: 'Get Patch Version' + id: 'patch_version' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + PATCH_FROM: '${{ github.event.inputs.type }}' + CLI_PACKAGE_NAME: '${{vars.CLI_PACKAGE_NAME}}' + run: | + # Use the existing get-release-version.js script to calculate patch version + # Run from main checkout which has full git history and access to npm + PATCH_JSON=$(node scripts/get-release-version.js --type=patch --cli-package-name="${CLI_PACKAGE_NAME}" --patch-from="${PATCH_FROM}") + echo "Patch version calculation result: ${PATCH_JSON}" + + RELEASE_VERSION=$(echo "${PATCH_JSON}" | jq -r .releaseVersion) + RELEASE_TAG=$(echo "${PATCH_JSON}" | jq -r .releaseTag) + NPM_TAG=$(echo "${PATCH_JSON}" | jq -r .npmTag) + PREVIOUS_TAG=$(echo "${PATCH_JSON}" | jq -r .previousReleaseTag) + + echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_OUTPUT}" + echo "RELEASE_TAG=${RELEASE_TAG}" >> "${GITHUB_OUTPUT}" + echo "NPM_TAG=${NPM_TAG}" >> "${GITHUB_OUTPUT}" + echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "${GITHUB_OUTPUT}" + + - name: 'Verify Version Consistency' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + CHANNEL: '${{ github.event.inputs.type }}' + ORIGINAL_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + ORIGINAL_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + ORIGINAL_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' + VARS_CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' + run: | + echo "๐Ÿ” Verifying no concurrent patch releases have occurred..." + + # Store original calculation for comparison + echo "Original calculation:" + echo " Release version: ${ORIGINAL_RELEASE_VERSION}" + echo " Release tag: ${ORIGINAL_RELEASE_TAG}" + echo " Previous tag: ${ORIGINAL_PREVIOUS_TAG}" + + # Re-run the same version calculation script + echo "Re-calculating version to check for changes..." + CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${VARS_CLI_PACKAGE_NAME}" --type=patch --patch-from="${CHANNEL}") + CURRENT_RELEASE_VERSION=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseVersion) + CURRENT_RELEASE_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseTag) + CURRENT_PREVIOUS_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .previousReleaseTag) + + echo "Current calculation:" + echo " Release version: ${CURRENT_RELEASE_VERSION}" + echo " Release tag: ${CURRENT_RELEASE_TAG}" + echo " Previous tag: ${CURRENT_PREVIOUS_TAG}" + + # Compare calculations + if [[ "${ORIGINAL_RELEASE_VERSION}" != "${CURRENT_RELEASE_VERSION}" ]] || \ + [[ "${ORIGINAL_RELEASE_TAG}" != "${CURRENT_RELEASE_TAG}" ]] || \ + [[ "${ORIGINAL_PREVIOUS_TAG}" != "${CURRENT_PREVIOUS_TAG}" ]]; then + echo "โŒ RACE CONDITION DETECTED: Version calculations have changed!" + echo "This indicates another patch release completed while this one was in progress." + echo "" + echo "Originally planned: ${ORIGINAL_RELEASE_VERSION} (from ${ORIGINAL_PREVIOUS_TAG})" + echo "Should now build: ${CURRENT_RELEASE_VERSION} (from ${CURRENT_PREVIOUS_TAG})" + echo "" + echo "# Setting outputs for failure comment" + echo "CURRENT_RELEASE_VERSION=${CURRENT_RELEASE_VERSION}" >> "${GITHUB_ENV}" + echo "CURRENT_RELEASE_TAG=${CURRENT_RELEASE_TAG}" >> "${GITHUB_ENV}" + echo "CURRENT_PREVIOUS_TAG=${CURRENT_PREVIOUS_TAG}" >> "${GITHUB_ENV}" + echo "The patch release must be restarted to use the correct version numbers." + exit 1 + fi + + echo "โœ… Version calculations unchanged - proceeding with release" + + - name: 'Print Calculated Version' + run: |- + echo "Patch Release Summary:" + echo " Release Version: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION}" + echo " Release Tag: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG}" + echo " NPM Tag: ${STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG}" + echo " Previous Tag: ${STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG}" + env: + STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' + STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' + + - name: 'Run Tests' + if: "${{github.event.inputs.force_skip_tests != 'true'}}" + uses: './.github/actions/run-tests' + with: + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + working-directory: './release' + + - name: 'Publish Release' + uses: './.github/actions/publish-release' + with: + release-version: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + release-tag: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + npm-tag: '${{ steps.patch_version.outputs.NPM_TAG }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + dry-run: '${{ github.event.inputs.dry_run }}' + previous-tag: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' + npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' + npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' + cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' + core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' + a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' + working-directory: './release' + + - name: 'Create Issue on Failure' + if: '${{ failure() && github.event.inputs.dry_run == false }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + gh issue create \ + --title 'Patch Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ + --body 'The patch-release workflow failed. See the full run for details: ${DETAILS_URL}' \ + --label 'release-failure,priority/p0' + + - name: 'Comment Success on Original PR' + if: '${{ success() && github.event.inputs.original_pr }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' + SUCCESS: 'true' + RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' + CHANNEL: '${{ github.event.inputs.type }}' + DRY_RUN: '${{ github.event.inputs.dry_run }}' + GITHUB_RUN_ID: '${{ github.run_id }}' + GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' + GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' + run: | + node scripts/releasing/patch-comment.js + + - name: 'Comment Failure on Original PR' + if: '${{ failure() && github.event.inputs.original_pr }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' + SUCCESS: 'false' + RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' + CHANNEL: '${{ github.event.inputs.type }}' + DRY_RUN: '${{ github.event.inputs.dry_run }}' + GITHUB_RUN_ID: '${{ github.run_id }}' + GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' + GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' + # Pass current version info for race condition failures + CURRENT_RELEASE_VERSION: '${{ env.CURRENT_RELEASE_VERSION }}' + CURRENT_RELEASE_TAG: '${{ env.CURRENT_RELEASE_TAG }}' + CURRENT_PREVIOUS_TAG: '${{ env.CURRENT_PREVIOUS_TAG }}' + run: | + # Check if this was a version consistency failure + if [[ -n "${CURRENT_RELEASE_VERSION}" ]]; then + echo "Detected version race condition failure - posting specific comment with current version info" + export RACE_CONDITION_FAILURE=true + fi + node scripts/releasing/patch-comment.js diff --git a/.github/workflows/release-promote.yml b/.github/workflows/release-promote.yml new file mode 100644 index 0000000000000000000000000000000000000000..d6f0854e3344178bf28d0bafe0512e2a825a248e --- /dev/null +++ b/.github/workflows/release-promote.yml @@ -0,0 +1,441 @@ +name: 'Release: Promote' + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.' + required: true + type: 'boolean' + default: true + force_skip_tests: + description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests' + required: false + type: 'boolean' + default: false + ref: + description: 'The branch, tag, or SHA to release from.' + required: false + type: 'string' + default: 'main' + stable_version_override: + description: 'Manually override the stable version number.' + required: false + type: 'string' + preview_version_override: + description: 'Manually override the preview version number.' + required: false + type: 'string' + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + calculate-versions: + name: 'Calculate Versions and Plan' + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + + outputs: + STABLE_VERSION: '${{ steps.versions.outputs.STABLE_VERSION }}' + STABLE_SHA: '${{ steps.versions.outputs.STABLE_SHA }}' + PREVIOUS_STABLE_TAG: '${{ steps.versions.outputs.PREVIOUS_STABLE_TAG }}' + PREVIEW_VERSION: '${{ steps.versions.outputs.PREVIEW_VERSION }}' + PREVIEW_SHA: '${{ steps.versions.outputs.PREVIEW_SHA }}' + PREVIOUS_PREVIEW_TAG: '${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}' + NEXT_NIGHTLY_VERSION: '${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}' + PREVIOUS_NIGHTLY_TAG: '${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + fetch-depth: 0 + fetch-tags: true + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Print Inputs' + shell: 'bash' + env: + JSON_INPUTS: '${{ toJSON(inputs) }}' + run: 'echo "$JSON_INPUTS"' + + - name: 'Calculate Versions and SHAs' + id: 'versions' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + STABLE_OVERRIDE: '${{ github.event.inputs.stable_version_override }}' + PREVIEW_OVERRIDE: '${{ github.event.inputs.preview_version_override }}' + REF_INPUT: '${{ github.event.inputs.ref }}' + run: | + set -e + STABLE_COMMAND="node scripts/get-release-version.js --type=stable" + if [[ -n "${STABLE_OVERRIDE}" ]]; then + STABLE_COMMAND+=" --stable_version_override=${STABLE_OVERRIDE}" + fi + PREVIEW_COMMAND="node scripts/get-release-version.js --type=preview" + if [[ -n "${PREVIEW_OVERRIDE}" ]]; then + PREVIEW_COMMAND+=" --preview_version_override=${PREVIEW_OVERRIDE}" + fi + NIGHTLY_COMMAND="node scripts/get-release-version.js --type=promote-nightly" + STABLE_JSON=$(${STABLE_COMMAND}) + STABLE_VERSION=$(echo "${STABLE_JSON}" | jq -r .releaseVersion) + PREVIEW_COMMAND+=" --stable-base-version=${STABLE_VERSION}" + NIGHTLY_COMMAND+=" --stable-base-version=${STABLE_VERSION}" + PREVIEW_JSON=$(${PREVIEW_COMMAND}) + NIGHTLY_JSON=$(${NIGHTLY_COMMAND}) + echo "STABLE_JSON_COMMAND=${STABLE_COMMAND}" + echo "PREVIEW_JSON_COMMAND=${PREVIEW_COMMAND}" + echo "NIGHTLY_JSON_COMMAND=${NIGHTLY_COMMAND}" + echo "STABLE_JSON: ${STABLE_JSON}" + echo "PREVIEW_JSON: ${PREVIEW_JSON}" + echo "NIGHTLY_JSON: ${NIGHTLY_JSON}" + echo "STABLE_VERSION=${STABLE_VERSION}" >> "${GITHUB_OUTPUT}" + # shellcheck disable=SC1083 + PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag) + STABLE_SHA=$(git rev-parse "${PREVIOUS_PREVIEW_TAG}^{commit}") + echo "STABLE_SHA=${STABLE_SHA}" >> "${GITHUB_OUTPUT}" + echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" + echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}" + # shellcheck disable=SC1083 + REF="${REF_INPUT}" + SHA=$(git ls-remote origin "$REF" | awk -v ref="$REF" '$2 == "refs/heads/"ref || $2 == "refs/tags/"ref || $2 == ref {print $1}' | head -n 1) + if [ -z "$SHA" ]; then + if [[ "$REF" =~ ^[0-9a-f]{7,40}$ ]]; then + SHA="$REF" + else + echo "::error::Could not resolve ref '$REF' to a commit SHA." + exit 1 + fi + fi + echo "PREVIEW_SHA=$SHA" >> "${GITHUB_OUTPUT}" + echo "PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" + echo "NEXT_NIGHTLY_VERSION=$(echo "${NIGHTLY_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}" + echo "PREVIOUS_NIGHTLY_TAG=$(echo "${NIGHTLY_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" + CURRENT_NIGHTLY_TAG=$(git describe --tags --abbrev=0 --match="*nightly*") + echo "CURRENT_NIGHTLY_TAG=${CURRENT_NIGHTLY_TAG}" >> "${GITHUB_OUTPUT}" + echo "NEXT_SHA=$SHA" >> "${GITHUB_OUTPUT}" + + - name: 'Display Pending Updates' + env: + STABLE_VERSION: '${{ steps.versions.outputs.STABLE_VERSION }}' + STABLE_SHA: '${{ steps.versions.outputs.STABLE_SHA }}' + PREVIOUS_STABLE_TAG: '${{ steps.versions.outputs.PREVIOUS_STABLE_TAG }}' + PREVIEW_VERSION: '${{ steps.versions.outputs.PREVIEW_VERSION }}' + PREVIEW_SHA: '${{ steps.versions.outputs.PREVIEW_SHA }}' + PREVIOUS_PREVIEW_TAG: '${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}' + NEXT_NIGHTLY_VERSION: '${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}' + PREVIOUS_NIGHTLY_TAG: '${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}' + INPUT_REF: '${{ github.event.inputs.ref }}' + run: | + echo "Release Plan:" + echo "-----------" + echo "Stable Release: ${STABLE_VERSION}" + echo " - Commit: ${STABLE_SHA}" + echo " - Previous Tag: ${PREVIOUS_STABLE_TAG}" + echo "" + echo "Preview Release: ${PREVIEW_VERSION}" + echo " - Commit: ${PREVIEW_SHA} (${INPUT_REF})" + echo " - Previous Tag: ${PREVIOUS_PREVIEW_TAG}" + echo "" + echo "Preparing Next Nightly Release: ${NEXT_NIGHTLY_VERSION}" + echo " - Merging Version Update PR to Branch: ${INPUT_REF}" + echo " - Previous Tag: ${PREVIOUS_NIGHTLY_TAG}" + + test: + name: 'Test ${{ matrix.channel }}' + needs: 'calculate-versions' + runs-on: 'ubuntu-latest' + strategy: + fail-fast: false + matrix: + include: + - channel: 'stable' + sha: '${{ needs.calculate-versions.outputs.STABLE_SHA }}' + - channel: 'preview' + sha: '${{ needs.calculate-versions.outputs.PREVIEW_SHA }}' + - channel: 'nightly' + sha: '${{ github.event.inputs.ref }}' + steps: + - name: 'Checkout Ref' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref }}' + + - name: 'Checkout correct SHA' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ matrix.sha }}' + path: 'release' + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + working-directory: './release' + run: 'npm ci' + + - name: 'Run Tests' + if: "${{github.event.inputs.force_skip_tests != 'true'}}" + uses: './.github/actions/run-tests' + with: + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + working-directory: './release' + + build-mac: + if: "github.repository == 'google-gemini/gemini-cli'" + uses: './.github/workflows/build-unsigned-mac-binaries.yml' + with: + ref: '${{ github.event.inputs.ref }}' + + publish-preview: + name: 'Publish preview' + needs: ['calculate-versions', 'test', 'build-mac'] + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + contents: 'write' + packages: 'write' + issues: 'write' + steps: + - name: 'Checkout Ref' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref }}' + + - name: 'Checkout correct SHA' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ needs.calculate-versions.outputs.PREVIEW_SHA }}' + path: 'release' + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + working-directory: './release' + run: 'npm ci' + + - name: 'Download macOS Binaries' + uses: './.github/actions/download-mac-binaries' + with: + path: 'release/dist' + + - name: 'Publish Release' + uses: './.github/actions/publish-release' + with: + release-version: '${{ needs.calculate-versions.outputs.PREVIEW_VERSION }}' + release-tag: 'v${{ needs.calculate-versions.outputs.PREVIEW_VERSION }}' + npm-tag: 'preview' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + dry-run: '${{ github.event.inputs.dry_run }}' + previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_PREVIEW_TAG }}' + working-directory: './release' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + force-skip-tests: '${{ github.event.inputs.force_skip_tests }}' + npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' + npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' + npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' + cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' + core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' + a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' + + - name: 'Create Issue on Failure' + if: '${{ failure() && github.event.inputs.dry_run == false }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: 'v${{ needs.calculate-versions.outputs.PREVIEW_VERSION }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + gh issue create \ + --title 'Promote Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ + --body 'The promote-release workflow failed during preview publish. See the full run for details: ${DETAILS_URL}' \ + --label 'release-failure,priority/p0' + + publish-stable: + name: 'Publish stable' + needs: ['calculate-versions', 'test', 'publish-preview', 'build-mac'] + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + contents: 'write' + packages: 'write' + issues: 'write' + steps: + - name: 'Checkout Ref' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref }}' + + - name: 'Checkout correct SHA' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ needs.calculate-versions.outputs.STABLE_SHA }}' + path: 'release' + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + working-directory: './release' + run: 'npm ci' + + - name: 'Download macOS Binaries' + uses: './.github/actions/download-mac-binaries' + with: + path: 'release/dist' + + - name: 'Publish Release' + uses: './.github/actions/publish-release' + with: + release-version: '${{ needs.calculate-versions.outputs.STABLE_VERSION }}' + release-tag: 'v${{ needs.calculate-versions.outputs.STABLE_VERSION }}' + npm-tag: 'latest' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + github-release-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + dry-run: '${{ github.event.inputs.dry_run }}' + previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_STABLE_TAG }}' + working-directory: './release' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + force-skip-tests: '${{ github.event.inputs.force_skip_tests }}' + npm-registry-publish-url: '${{ vars.NPM_REGISTRY_PUBLISH_URL }}' + npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' + npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' + cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' + core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' + a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' + + - name: 'Create Issue on Failure' + if: '${{ failure() && github.event.inputs.dry_run == false }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: 'v${{ needs.calculate-versions.outputs.STABLE_VERSION }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + gh issue create \ + --title 'Promote Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ + --body 'The promote-release workflow failed during stable publish. See the full run for details: ${DETAILS_URL}' \ + --label 'release-failure,priority/p0' + + nightly-pr: + name: 'Create Nightly PR' + needs: ['publish-stable', 'calculate-versions'] + runs-on: 'ubuntu-latest' + environment: "${{ github.event.inputs.environment || 'prod' }}" + permissions: + contents: 'write' + pull-requests: 'write' + issues: 'write' + steps: + - name: 'Checkout Ref' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref }}' + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Configure Git User' + run: |- + git config user.name "gemini-cli-robot" + git config user.email "gemini-cli-robot@google.com" + + - name: 'Create and switch to a new branch' + id: 'release_branch' + run: | + BRANCH_NAME="chore/nightly-version-bump-${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" + git switch -c "${BRANCH_NAME}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + env: + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' + + - name: 'Update package versions' + run: 'npm run release:version "${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}"' + env: + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' + + - name: 'Commit and Push package versions' + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + DRY_RUN: '${{ github.event.inputs.dry_run }}' + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' + GIT_PUSH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + run: |- + git add package.json packages/*/package.json + if [ -f package-lock.json ]; then + git add package-lock.json + fi + git commit -m "chore(release): bump version to ${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" + if [[ "${DRY_RUN}" == "false" ]]; then + echo "Pushing release branch to remote..." + git push "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH_NAME}" --follow-tags + else + echo "Dry run enabled. Skipping push." + fi + + - name: 'Create and Merge Pull Request' + uses: './.github/actions/create-pull-request' + with: + branch-name: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + pr-title: 'chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' + pr-body: 'Automated version bump to prepare for the next nightly release.' + github-token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' + dry-run: '${{ github.event.inputs.dry_run }}' + + - name: 'Create Issue on Failure' + if: '${{ failure() && github.event.inputs.dry_run == false }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + RELEASE_TAG: 'v${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + gh issue create \ + --title 'Promote Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ + --body 'The promote-release workflow failed during nightly PR creation. See the full run for details: ${DETAILS_URL}' \ + --label 'release-failure,priority/p0' diff --git a/.github/workflows/release-rollback.yml b/.github/workflows/release-rollback.yml new file mode 100644 index 0000000000000000000000000000000000000000..56af1d6e66e5a3b3f6f47e81bc045972467ac049 --- /dev/null +++ b/.github/workflows/release-rollback.yml @@ -0,0 +1,244 @@ +name: 'Release: Rollback change' + +on: + workflow_dispatch: + inputs: + rollback_origin: + description: 'The package version to rollback FROM and delete (e.g., 0.5.0-preview-2)' + required: true + type: 'string' + rollback_destination: + description: 'The package version to rollback TO (e.g., 0.5.0-preview-2). This version must already exist on the npm registry.' + required: false + type: 'string' + channel: + description: 'The npm dist-tag to apply to rollback_destination (e.g., latest, preview, nightly). REQUIRED IF rollback_destination is set.' + required: false + type: 'choice' + options: + - 'latest' + - 'preview' + - 'nightly' + - 'dev' + default: 'dev' + ref: + description: 'The branch, tag, or SHA to run from.' + required: false + type: 'string' + default: 'main' + dry-run: + description: 'Whether to run in dry-run mode.' + required: false + type: 'boolean' + default: true + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + change-tags: + if: "github.repository == 'google-gemini/gemini-cli'" + environment: "${{ github.event.inputs.environment || 'prod' }}" + runs-on: 'ubuntu-latest' + permissions: + packages: 'write' + issues: 'write' + steps: + - name: 'Checkout repository' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4 + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref }}' + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + + - name: 'configure .npmrc' + uses: './.github/actions/setup-npmrc' + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + + - name: 'Get Origin Version Tag' + id: 'origin_tag' + shell: 'bash' + env: + ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' + run: | + TAG_VALUE="v${ROLLBACK_ORIGIN}" + echo "ORIGIN_TAG=$TAG_VALUE" >> "$GITHUB_OUTPUT" + + - name: 'Get Origin Commit Hash' + id: 'origin_hash' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' + shell: 'bash' + run: | + ORIGIN_HASH=$(git rev-parse "${ORIGIN_TAG}") + echo "ORIGIN_HASH=${ORIGIN_HASH}" >> "$GITHUB_OUTPUT" + + - name: 'Change tag' + if: "${{ github.event.inputs.rollback_destination != '' }}" + uses: './.github/actions/tag-npm-release' + with: + channel: '${{ github.event.inputs.channel }}' + version: '${{ github.event.inputs.rollback_destination }}' + dry-run: '${{ github.event.inputs.dry-run }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + cli-package-name: '${{ vars.CLI_PACKAGE_NAME }}' + core-package-name: '${{ vars.CORE_PACKAGE_NAME }}' + a2a-package-name: '${{ vars.A2A_PACKAGE_NAME }}' + + - name: 'Get cli Token' + uses: './.github/actions/npm-auth-token' + id: 'cli-token' + with: + package-name: '${{ vars.CLI_PACKAGE_NAME }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + + - name: 'Deprecate Cli Npm Package' + if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod' }}" + env: + NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' + PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' + ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' + shell: 'bash' + run: | + npm deprecate "${PACKAGE_NAME}@${ROLLBACK_ORIGIN}" "This version has been rolled back." + + - name: 'Get core Token' + uses: './.github/actions/npm-auth-token' + id: 'core-token' + with: + package-name: '${{ vars.CLI_PACKAGE_NAME }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + + - name: 'Deprecate Core Npm Package' + if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod' }}" + env: + NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' + PACKAGE_NAME: '${{ vars.CORE_PACKAGE_NAME }}' + ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' + shell: 'bash' + run: | + npm deprecate "${PACKAGE_NAME}@${ROLLBACK_ORIGIN}" "This version has been rolled back." + + - name: 'Get a2a Token' + uses: './.github/actions/npm-auth-token' + id: 'a2a-token' + with: + package-name: '${{ vars.A2A_PACKAGE_NAME }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}' + wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}' + wombat-token-a2a-server: '${{ secrets.WOMBAT_TOKEN_A2A_SERVER }}' + + - name: 'Deprecate A2A Server Npm Package' + if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod' }}" + env: + NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' + PACKAGE_NAME: '${{ vars.A2A_PACKAGE_NAME }}' + ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' + shell: 'bash' + run: | + npm deprecate "${PACKAGE_NAME}@${ROLLBACK_ORIGIN}" "This version has been rolled back." + + - name: 'Delete Github Release' + if: "${{ github.event.inputs.dry-run == 'false' && github.event.inputs.environment == 'prod'}}" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' + shell: 'bash' + run: | + gh release delete "${ORIGIN_TAG}" --yes + + - name: 'Verify Origin Release Deletion' + if: "${{ github.event.inputs.dry-run == 'false' }}" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + TARGET_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' + shell: 'bash' + run: | + RELEASE_TAG=$(gh release view "$TARGET_TAG" --json tagName --jq .tagName) + if [ "$RELEASE_TAG" = "$TARGET_TAG" ]; then + echo "โŒ Failed to delete release with tag ${TARGET_TAG}" + echo 'โŒ This means the release was not deleted, and the workflow should fail.' + exit 1 + fi + + - name: 'Add Rollback Tag' + id: 'rollback_tag' + if: "${{ github.event.inputs.dry-run == 'false' }}" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ROLLBACK_TAG_NAME: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}-rollback' + ORIGIN_HASH: '${{ steps.origin_hash.outputs.ORIGIN_HASH }}' + shell: 'bash' + run: | + echo "ROLLBACK_TAG=$ROLLBACK_TAG_NAME" >> "$GITHUB_OUTPUT" + git tag "$ROLLBACK_TAG_NAME" "${ORIGIN_HASH}" + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" --tags + + - name: 'Verify Rollback Tag Added' + if: "${{ github.event.inputs.dry-run == 'false' }}" + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + TARGET_TAG: '${{ steps.rollback_tag.outputs.ROLLBACK_TAG }}' + TARGET_HASH: '${{ steps.origin_hash.outputs.ORIGIN_HASH }}' + shell: 'bash' + run: | + ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG") + if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then + echo "โŒ Failed to add tag ${TARGET_TAG} to commit ${TARGET_HASH}" + echo 'โŒ This means the tag was not added, and the workflow should fail.' + exit 1 + fi + + - name: 'Log Dry run' + if: "${{ github.event.inputs.dry-run == 'true' }}" + env: + ROLLBACK_ORIGIN: '${{ github.event.inputs.rollback_origin }}' + ROLLBACK_DESTINATION: '${{ github.event.inputs.rollback_destination }}' + CHANNEL: '${{ github.event.inputs.channel }}' + REF_INPUT: '${{ github.event.inputs.ref }}' + ORIGIN_TAG: '${{ steps.origin_tag.outputs.ORIGIN_TAG }}' + ORIGIN_HASH: '${{ steps.origin_hash.outputs.ORIGIN_HASH }}' + ROLLBACK_TAG: '${{ steps.rollback_tag.outputs.ROLLBACK_TAG }}' + CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' + CORE_PACKAGE_NAME: '${{ vars.CORE_PACKAGE_NAME }}' + A2A_PACKAGE_NAME: '${{ vars.A2A_PACKAGE_NAME }}' + shell: 'bash' + run: | + echo " + Inputs: + - rollback_origin: '${ROLLBACK_ORIGIN}' + - rollback_destination: '${ROLLBACK_DESTINATION}' + - channel: '${CHANNEL}' + - ref: '${REF_INPUT}' + + Outputs: + - ORIGIN_TAG: '${ORIGIN_TAG}' + - ORIGIN_HASH: '${ORIGIN_HASH}' + - ROLLBACK_TAG: '${ROLLBACK_TAG}' + + Would have npm deprecate ${CLI_PACKAGE_NAME}@${ROLLBACK_ORIGIN}, ${CORE_PACKAGE_NAME}@${ROLLBACK_ORIGIN}, and ${A2A_PACKAGE_NAME}@${ROLLBACK_ORIGIN} + Would have deleted the github release with tag ${ORIGIN_TAG} + Would have added tag ${ORIGIN_TAG}-rollback to ${ORIGIN_HASH} + " diff --git a/.github/workflows/release-sandbox.yml b/.github/workflows/release-sandbox.yml new file mode 100644 index 0000000000000000000000000000000000000000..033ad45007794d182d7c25a8ea6ef1ed2e306713 --- /dev/null +++ b/.github/workflows/release-sandbox.yml @@ -0,0 +1,51 @@ +name: 'Release Sandbox' + +on: + workflow_dispatch: + inputs: + ref: + description: 'The branch, tag, or SHA to release from.' + required: false + type: 'string' + default: 'main' + dry-run: + description: 'Whether this is a dry run.' + required: false + type: 'boolean' + default: true + +jobs: + build: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + permissions: + contents: 'read' + packages: 'write' + issues: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + ref: '${{ github.event.inputs.ref || github.sha }}' + fetch-depth: 0 + - name: 'Push' + uses: './.github/actions/push-sandbox' + with: + dockerhub-username: '${{ secrets.DOCKER_SERVICE_ACCOUNT_NAME }}' + dockerhub-token: '${{ secrets.DOCKER_SERVICE_ACCOUNT_KEY }}' + github-actor: '${{ github.actor }}' + github-secret: '${{ secrets.GITHUB_TOKEN }}' + github-sha: '${{ github.sha }}' + github-ref-name: '${{github.event.inputs.ref}}' + dry-run: '${{ github.event.inputs.dry-run }}' + - name: 'Create Issue on Failure' + if: '${{ failure() && github.event.inputs.dry-run == false }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: | + gh issue create \ + --title 'Sandbox Release Failed on $(date +'%Y-%m-%d')' \ + --body 'The sandbox-release workflow failed. See the full run for details: ${DETAILS_URL}' \ + --label 'release-failure,priority/p0' diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 0000000000000000000000000000000000000000..41a9f927d6386c167f886b7093fd19143413ceea --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,52 @@ +name: 'On Merge Smoke Test' + +on: + push: + branches: + - 'main' + - 'release/**' + workflow_dispatch: + inputs: + ref: + description: 'The branch, tag, or SHA to test on.' + required: false + type: 'string' + default: 'main' + dry-run: + description: 'Run a dry-run of the smoke test; No bug will be created' + required: true + type: 'boolean' + default: true + +jobs: + smoke-test: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + packages: 'write' + issues: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + ref: '${{ github.event.inputs.ref || github.sha }}' + fetch-depth: 0 + persist-credentials: false + - name: 'Install Dependencies' + run: 'npm ci' + - name: 'Build bundle' + run: 'npm run bundle' + - name: 'Smoke test bundle' + run: 'node ./bundle/gemini.js --version' + - name: 'Create Issue on Failure' + if: '${{ failure() && github.event.inputs.dry-run == false }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + REF: '${{ github.event.inputs.ref }}' + run: | + gh issue create \ + --title 'Smoke test failed on ${REF} @ $(date +'%Y-%m-%d')' \ + --body 'Smoke test build failed. See the full run for details: ${DETAILS_URL}' \ + --label 'priority/p0' diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml new file mode 100644 index 0000000000000000000000000000000000000000..e1ad5832ab1d652421e808a0fc9d8547789e4009 --- /dev/null +++ b/.github/workflows/test-build-binary.yml @@ -0,0 +1,163 @@ +name: 'Test Build Binary' + +on: + workflow_dispatch: + +permissions: + contents: 'read' + +defaults: + run: + shell: 'bash' + +jobs: + build-node-binary: + name: 'Build Binary (${{ matrix.os }})' + runs-on: '${{ matrix.os }}' + strategy: + fail-fast: false + matrix: + include: + - os: 'ubuntu-latest' + platform_name: 'linux-x64' + arch: 'x64' + - os: 'windows-latest' + platform_name: 'win32-x64' + arch: 'x64' + - os: 'macos-latest' # Apple Silicon (ARM64) + platform_name: 'darwin-arm64' + arch: 'arm64' + - os: 'macos-latest' # Intel (x64) running on ARM via Rosetta + platform_name: 'darwin-x64' + arch: 'x64' + + steps: + - name: 'Checkout' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 + with: + persist-credentials: false + + - name: 'Optimize Windows Performance' + if: "matrix.os == 'windows-latest'" + run: | + Set-MpPreference -DisableRealtimeMonitoring $true + Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue + Set-Service -Name "wsearch" -StartupType Disabled + Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue + Set-Service -Name "SysMain" -StartupType Disabled + shell: 'powershell' + + - name: 'Set up Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + architecture: '${{ matrix.arch }}' + cache: 'npm' + + - name: 'Install dependencies' + run: 'npm ci' + + - name: 'Check Secrets' + id: 'check_secrets' + run: | + echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT" + + - name: 'Setup Windows SDK (Windows)' + if: "matrix.os == 'windows-latest'" + uses: 'microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce' # ratchet:microsoft/setup-msbuild@v2 + + - name: 'Add Signtool to Path (Windows)' + if: "matrix.os == 'windows-latest'" + run: | + $signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName + echo "Found signtool at: $signtoolPath" + echo "$signtoolPath" >> $env:GITHUB_PATH + shell: 'pwsh' + + - name: 'Setup macOS Keychain' + if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'" + env: + BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}' + P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}' + KEYCHAIN_PASSWORD: 'temp-password' + run: | + # Create the P12 file + echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12 + + # Create a temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + + # Import the certificate + security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign + + # Allow codesign to access it + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain + + # Set Identity for build script + echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV" + + - name: 'Setup Windows Certificate' + if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'" + env: + PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}' + PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}' + run: | + $pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64") + $certPath = Join-Path (Get-Location) "cert.pfx" + [IO.File]::WriteAllBytes($certPath, $pfx_cert_byte) + echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV + echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV + shell: 'pwsh' + + - name: 'Build Binary' + run: 'npm run build:binary' + + - name: 'Build Core Package' + run: 'npm run build -w @google/gemini-cli-core' + + - name: 'Verify Output Exists' + run: | + if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini" + elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe" + else + echo "Error: Binary not found in dist/${{ matrix.platform_name }}/" + ls -R dist/ + exit 1 + fi + + - name: 'Smoke Test Binary' + run: | + echo "Running binary smoke test..." + if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then + "./dist/${{ matrix.platform_name }}/gemini.exe" --version + else + "./dist/${{ matrix.platform_name }}/gemini" --version + fi + + - name: 'Run Integration Tests' + if: "github.event_name != 'pull_request'" + env: + GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_CLI_TRUST_WORKSPACE: true + run: | + echo "Running integration tests with binary..." + if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then + BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")" + else + BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini" + fi + echo "Using binary at $BINARY_PATH" + export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH" + npm run test:integration:sandbox:none -- --testTimeout=600000 + + - name: 'Upload Artifact' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'gemini-cli-${{ matrix.platform_name }}' + path: 'dist/${{ matrix.platform_name }}/' + retention-days: 5 diff --git a/.github/workflows/tools-python-ci.yml b/.github/workflows/tools-python-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..94c5cc09f3bb138f487ce0e9b78f6792f9d61f16 --- /dev/null +++ b/.github/workflows/tools-python-ci.yml @@ -0,0 +1,45 @@ +name: 'Testing: Tools (Python)' + +on: + push: + branches: + - 'main' + - 'release/**' + paths: + - 'tools/**' + pull_request: + branches: + - 'main' + - 'release/**' + paths: + - 'tools/**' + +defaults: + run: + shell: 'bash' + +jobs: + python-tests: + name: 'Python Tests' + runs-on: 'ubuntu-latest' + steps: + - name: 'Checkout' + uses: 'actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683' # ratchet:actions/checkout@v4 + + - name: 'Set up Python' + uses: 'actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55' # ratchet:actions/setup-python@v5 + with: + python-version: '3.13' + cache: 'pip' + cache-dependency-path: 'tools/caretaker-agent/cloudrun/triage-worker/requirements.txt' + + - name: 'Install dependencies' + run: | + python -m pip install --upgrade pip + if [ -f tools/caretaker-agent/cloudrun/triage-worker/requirements.txt ]; then + python -m pip install -r tools/caretaker-agent/cloudrun/triage-worker/requirements.txt + fi + + - name: 'Run unittest suite' + run: | + PYTHONPATH=tools/caretaker-agent/cloudrun/triage-worker python -m unittest discover -s tools/caretaker-agent/cloudrun/triage-worker/tests -t tools/caretaker-agent/cloudrun/triage-worker diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml new file mode 100644 index 0000000000000000000000000000000000000000..56da2727c580bb6ddc09b6827fdec2c5b5fc2f97 --- /dev/null +++ b/.github/workflows/trigger_e2e.yml @@ -0,0 +1,40 @@ +name: 'Trigger E2E' + +on: + workflow_dispatch: + inputs: + repo_name: + description: 'Repository name (e.g., owner/repo)' + required: false + type: 'string' + head_sha: + description: 'SHA of the commit to test' + required: false + type: 'string' + pull_request: + +jobs: + save_repo_name: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'gemini-cli-ubuntu-16-core' + steps: + - name: 'Save Repo name' + env: + REPO_NAME: '${{ github.event.inputs.repo_name || github.event.pull_request.head.repo.full_name }}' + HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}' + run: | + mkdir -p ./pr + echo "${REPO_NAME}" > ./pr/repo_name + echo "${HEAD_SHA}" > ./pr/head_sha + - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'repo_name' + path: 'pr/' + trigger_e2e: + name: 'Trigger e2e' + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'gemini-cli-ubuntu-16-core' + steps: + - id: 'trigger-e2e' + run: | + echo "Trigger e2e workflow" diff --git a/.github/workflows/unassign-inactive-assignees.yml b/.github/workflows/unassign-inactive-assignees.yml new file mode 100644 index 0000000000000000000000000000000000000000..e3b9905b5df70cef572d669a222d09f834094f21 --- /dev/null +++ b/.github/workflows/unassign-inactive-assignees.yml @@ -0,0 +1,315 @@ +name: 'Unassign Inactive Issue Assignees' + +# This workflow runs daily and scans every open "help wanted" issue that has +# one or more assignees. For each assignee it checks whether they have a +# non-draft pull request (open and ready for review, or already merged) that +# is linked to the issue. Draft PRs are intentionally excluded so that +# contributors cannot reset the check by opening a no-op PR. If no +# qualifying PR is found within 7 days of assignment the assignee is +# automatically removed and a friendly comment is posted so that other +# contributors can pick up the work. +# Maintainers, org members, and collaborators (anyone with write access or +# above) are always exempted and will never be auto-unassigned. + +on: + schedule: + - cron: '0 9 * * *' # Every day at 09:00 UTC + workflow_dispatch: + inputs: + dry_run: + description: 'Run in dry-run mode (no changes will be applied)' + required: false + default: false + type: 'boolean' + +concurrency: + group: '${{ github.workflow }}' + cancel-in-progress: true + +defaults: + run: + shell: 'bash' + +jobs: + unassign-inactive-assignees: + if: "github.repository == 'google-gemini/gemini-cli'" + runs-on: 'ubuntu-latest' + permissions: + issues: 'write' + + steps: + - name: 'Generate GitHub App Token' + id: 'generate_token' + uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2 + with: + app-id: '${{ secrets.APP_ID }}' + private-key: '${{ secrets.PRIVATE_KEY }}' + + - name: 'Unassign inactive assignees' + uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7 + env: + DRY_RUN: '${{ inputs.dry_run }}' + with: + github-token: '${{ steps.generate_token.outputs.token }}' + script: | + const dryRun = process.env.DRY_RUN === 'true'; + if (dryRun) { + core.info('DRY RUN MODE ENABLED: No changes will be applied.'); + } + + const owner = context.repo.owner; + const repo = context.repo.repo; + const GRACE_PERIOD_DAYS = 7; + const now = new Date(); + + let maintainerLogins = new Set(); + const teams = ['gemini-cli-maintainers', 'gemini-cli-askmode-approvers', 'gemini-cli-docs']; + + for (const team_slug of teams) { + try { + const members = await github.paginate(github.rest.teams.listMembersInOrg, { + org: owner, + team_slug, + }); + for (const m of members) maintainerLogins.add(m.login.toLowerCase()); + core.info(`Fetched ${members.length} members from team ${team_slug}.`); + } catch (e) { + core.warning(`Could not fetch team ${team_slug}: ${e.message}`); + } + } + + const isGooglerCache = new Map(); + const isGoogler = async (login) => { + if (isGooglerCache.has(login)) return isGooglerCache.get(login); + try { + for (const org of ['googlers', 'google']) { + try { + await github.rest.orgs.checkMembershipForUser({ org, username: login }); + isGooglerCache.set(login, true); + return true; + } catch (e) { + if (e.status !== 404) throw e; + } + } + } catch (e) { + core.warning(`Could not check org membership for ${login}: ${e.message}`); + } + isGooglerCache.set(login, false); + return false; + }; + + const permissionCache = new Map(); + const isPrivilegedUser = async (login) => { + if (maintainerLogins.has(login.toLowerCase())) return true; + + if (permissionCache.has(login)) return permissionCache.get(login); + + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: login, + }); + const privileged = ['admin', 'maintain', 'write', 'triage'].includes(data.permission); + permissionCache.set(login, privileged); + if (privileged) { + core.info(` @${login} is a repo collaborator (${data.permission}) โ€” exempt.`); + return true; + } + } catch (e) { + if (e.status !== 404) { + core.warning(`Could not check permission for ${login}: ${e.message}`); + } + } + + const googler = await isGoogler(login); + permissionCache.set(login, googler); + return googler; + }; + + core.info('Fetching open "help wanted" issues with assignees...'); + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'open', + labels: 'help wanted', + per_page: 100, + }); + + const assignedIssues = issues.filter( + (issue) => !issue.pull_request && issue.assignees && issue.assignees.length > 0 + ); + + core.info(`Found ${assignedIssues.length} assigned "help wanted" issues.`); + + let totalUnassigned = 0; + + let timelineEvents = []; + try { + timelineEvents = await github.paginate(github.rest.issues.listEventsForTimeline, { + owner, + repo, + issue_number: issue.number, + per_page: 100, + mediaType: { previews: ['mockingbird'] }, + }); + } catch (err) { + core.warning(`Could not fetch timeline for issue #${issue.number}: ${err.message}`); + continue; + } + + const assignedAtMap = new Map(); + + for (const event of timelineEvents) { + if (event.event === 'assigned' && event.assignee) { + const login = event.assignee.login.toLowerCase(); + const at = new Date(event.created_at); + assignedAtMap.set(login, at); + } else if (event.event === 'unassigned' && event.assignee) { + assignedAtMap.delete(event.assignee.login.toLowerCase()); + } + } + + const linkedPRAuthorSet = new Set(); + const seenPRKeys = new Set(); + + for (const event of timelineEvents) { + if ( + event.event !== 'cross-referenced' || + !event.source || + event.source.type !== 'pull_request' || + !event.source.issue || + !event.source.issue.user || + !event.source.issue.number || + !event.source.issue.repository + ) continue; + + const prOwner = event.source.issue.repository.owner.login; + const prRepo = event.source.issue.repository.name; + const prNumber = event.source.issue.number; + const prAuthor = event.source.issue.user.login.toLowerCase(); + const prKey = `${prOwner}/${prRepo}#${prNumber}`; + + if (seenPRKeys.has(prKey)) continue; + seenPRKeys.add(prKey); + + try { + const { data: pr } = await github.rest.pulls.get({ + owner: prOwner, + repo: prRepo, + pull_number: prNumber, + }); + + const isReady = (pr.state === 'open' && !pr.draft) || + (pr.state === 'closed' && pr.merged_at !== null); + + core.info( + ` PR ${prKey} by @${prAuthor}: ` + + `state=${pr.state}, draft=${pr.draft}, merged=${!!pr.merged_at} โ†’ ` + + (isReady ? 'qualifies' : 'does NOT qualify (draft or closed without merge)') + ); + + if (isReady) linkedPRAuthorSet.add(prAuthor); + } catch (err) { + core.warning(`Could not fetch PR ${prKey}: ${err.message}`); + } + } + + const assigneesToRemove = []; + + for (const assignee of issue.assignees) { + const login = assignee.login.toLowerCase(); + + if (await isPrivilegedUser(assignee.login)) { + core.info(` @${assignee.login}: privileged user โ€” skipping.`); + continue; + } + + const assignedAt = assignedAtMap.get(login); + + if (!assignedAt) { + core.warning( + `No 'assigned' event found for @${login} on issue #${issue.number}; ` + + `falling back to issue creation date (${issue.created_at}).` + ); + assignedAtMap.set(login, new Date(issue.created_at)); + } + const resolvedAssignedAt = assignedAtMap.get(login); + + const daysSinceAssignment = (now - resolvedAssignedAt) / (1000 * 60 * 60 * 24); + + core.info( + ` @${login}: assigned ${daysSinceAssignment.toFixed(1)} day(s) ago, ` + + `ready-for-review PR: ${linkedPRAuthorSet.has(login) ? 'yes' : 'no'}` + ); + + if (daysSinceAssignment < GRACE_PERIOD_DAYS) { + core.info(` โ†’ within grace period, skipping.`); + continue; + } + + if (linkedPRAuthorSet.has(login)) { + core.info(` โ†’ ready-for-review PR found, keeping assignment.`); + continue; + } + + core.info(` โ†’ no ready-for-review PR after ${GRACE_PERIOD_DAYS} days, will unassign.`); + assigneesToRemove.push(assignee.login); + } + + if (assigneesToRemove.length === 0) { + continue; + } + + if (!dryRun) { + try { + await github.rest.issues.removeAssignees({ + owner, + repo, + issue_number: issue.number, + assignees: assigneesToRemove, + }); + } catch (err) { + core.warning( + `Failed to unassign ${assigneesToRemove.join(', ')} from issue #${issue.number}: ${err.message}` + ); + continue; + } + + const mentionList = assigneesToRemove.map((l) => `@${l}`).join(', '); + const commentBody = + `๐Ÿ‘‹ ${mentionList} โ€” it has been more than ${GRACE_PERIOD_DAYS} days since ` + + `you were assigned to this issue and we could not find a pull request ` + + `ready for review.\n\n` + + `To keep the backlog moving and ensure issues stay accessible to all ` + + `contributors, we require a PR that is open and ready for review (not a ` + + `draft) within ${GRACE_PERIOD_DAYS} days of assignment.\n\n` + + `We are automatically unassigning you so that other contributors can pick ` + + `this up. If you are still actively working on this, please:\n` + + `1. Re-assign yourself by commenting \`/assign\`.\n` + + `2. Open a PR (not a draft) linked to this issue (e.g. \`Fixes #${issue.number}\`) ` + + `within ${GRACE_PERIOD_DAYS} days so the automation knows real progress is being made.\n\n` + + `Thank you for your contribution โ€” we hope to see a PR from you soon! ๐Ÿ™`; + + try { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: commentBody, + }); + } catch (err) { + core.warning( + `Failed to post comment on issue #${issue.number}: ${err.message}` + ); + } + } + + totalUnassigned += assigneesToRemove.length; + core.info( + ` ${dryRun ? '[DRY RUN] Would have unassigned' : 'Unassigned'}: ${assigneesToRemove.join(', ')}` + ); + } + + core.info(`\nDone. Total assignees ${dryRun ? 'that would be' : ''} unassigned: ${totalUnassigned}`); diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml new file mode 100644 index 0000000000000000000000000000000000000000..964d574081b16a0c3bef7d44a18156b4ab8ae4b0 --- /dev/null +++ b/.github/workflows/verify-release.yml @@ -0,0 +1,58 @@ +name: 'Verify NPM release tag' + +on: + workflow_dispatch: + inputs: + version: + description: 'The expected Gemini binary version that should be released (e.g., 0.5.0-preview-2).' + required: true + type: 'string' + npm-tag: + description: 'NPM tag to verify' + required: true + type: 'choice' + options: + - 'dev' + - 'latest' + - 'preview' + - 'nightly' + default: 'latest' + environment: + description: 'Environment' + required: false + type: 'choice' + options: + - 'prod' + - 'dev' + default: 'prod' + +jobs: + verify-release: + if: "github.repository == 'google-gemini/gemini-cli'" + environment: "${{ github.event.inputs.environment || 'prod' }}" + strategy: + fail-fast: false + matrix: + os: ['ubuntu-latest', 'macos-latest', 'windows-latest'] + runs-on: '${{ matrix.os }}' + permissions: + contents: 'read' + packages: 'write' + issues: 'write' + steps: + - name: '๐Ÿ“ Print vars' + shell: 'bash' + run: 'echo "${{ toJSON(vars) }}"' + - uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' + with: + persist-credentials: false + - name: 'Verify release' + uses: './.github/actions/verify-release' + with: + npm-package: '${{vars.CLI_PACKAGE_NAME}}@${{github.event.inputs.npm-tag}}' + expected-version: '${{github.event.inputs.version}}' + working-directory: '.' + gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' + npm-registry-url: '${{ vars.NPM_REGISTRY_URL }}' + github-token: '${{ secrets.GITHUB_TOKEN }}' + npm-registry-scope: '${{ vars.NPM_REGISTRY_SCOPE }}' diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000000000000000000000000000000000..cbdbfd1748868de4f7f80d3e0a604f733d591224 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "vitest.explorer", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000000000000000000000000000000..01f0ba59a912f0c65aa66b3c80823cfa5700fad1 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,105 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Build & Launch CLI", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "build-and-start"], + "skipFiles": ["/**"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "env": { + "GEMINI_SANDBOX": "false" + } + }, + { + "name": "Launch Companion VS Code Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-companion" + ], + "outFiles": [ + "${workspaceFolder}/packages/vscode-ide-companion/dist/**/*.js" + ], + "preLaunchTask": "npm: build: vscode-ide-companion" + }, + { + "name": "Attach", + "port": 9229, + "request": "attach", + "skipFiles": ["/**"], + "type": "node", + // fix source mapping when debugging in sandbox using global installation + // note this does not interfere when remoteRoot is also ${workspaceFolder}/packages + "remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@gemini-cli", + "localRoot": "${workspaceFolder}/packages" + }, + { + "type": "node", + "request": "launch", + "name": "CLI: Run Current File", + "runtimeExecutable": "node", + "runtimeArgs": ["--import", "tsx"], + "skipFiles": ["/**"], + "program": "${file}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "outFiles": ["${workspaceFolder}/**/*.js"] + }, + { + "type": "node", + "request": "launch", + "name": "Debug Test File", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run", + "test", + "-w", + "packages", + "--", + "--inspect-brk=9229", + "--no-file-parallelism", + "${input:testFile}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["/**"] + }, + { + "name": "Debug Integration Test File", + "type": "node", + "request": "launch", + "runtimeExecutable": "npx", + "runtimeArgs": [ + "vitest", + "run", + "--root", + "./integration-tests", + "--inspect-brk=9229", + "${file}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["/**"], + "env": { + "GEMINI_SANDBOX": "false" + } + } + ], + "inputs": [ + { + "id": "testFile", + "type": "promptString", + "description": "Enter the path to the test file (e.g., ${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx)", + "default": "${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..3197edbbfccd5b2a3f76f768ae427e30e6151daf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "editor.tabSize": 2, + "editor.rulers": [80], + "editor.detectIndentation": false, + "editor.insertSpaces": true, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "vitest.disableWorkspaceWarning": true +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000000000000000000000000000000000..58709bc925b5b2620b1df3082d9035743ed1c18a --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,25 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "build", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [], + "label": "npm: build", + "detail": "scripts/build.sh" + }, + { + "type": "npm", + "script": "build", + "path": "packages/vscode-ide-companion", + "group": "build", + "problemMatcher": [], + "label": "npm: build: vscode-ide-companion", + "detail": "npm run build -w packages/vscode-ide-companion" + } + ] +} diff --git a/perf-tests/README.md b/perf-tests/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c8e9e448c15392641f92550122914e79bbcd5800 --- /dev/null +++ b/perf-tests/README.md @@ -0,0 +1,121 @@ +# CPU Performance Integration Test Harness + +## Overview + +This directory contains performance/CPU integration tests for the Gemini CLI. +These tests measure wall-clock time, CPU usage, and event loop responsiveness to +detect regressions across key scenarios. + +CPU performance is inherently noisy, especially in CI. The harness addresses +this with: + +- **IQR outlier filtering** โ€” discards anomalous samples +- **Median sampling** โ€” takes N runs, reports the median after filtering +- **Warmup runs** โ€” discards the first run to mitigate JIT compilation noise +- **15% default tolerance** โ€” won't panic at slight regressions + +## Running + +```bash +# Run tests (compare against committed baselines) +npm run test:perf + +# Update baselines (after intentional changes) +npm run test:perf:update-baselines + +# Verbose output +VERBOSE=true npm run test:perf + +# Keep test artifacts for debugging +KEEP_OUTPUT=true npm run test:perf +``` + +## How It Works + +### Measurement Primitives + +The `PerfTestHarness` class (in `packages/test-utils`) provides: + +- **`performance.now()`** โ€” high-resolution wall-clock timing +- **`process.cpuUsage()`** โ€” user + system CPU microseconds (delta between + start/stop) +- **`perf_hooks.monitorEventLoopDelay()`** โ€” event loop delay histogram + (p50/p95/p99/max) + +### Noise Reduction + +1. **Warmup**: First run is discarded to mitigate JIT compilation artifacts +2. **Multiple samples**: Each scenario runs N times (default 5) +3. **IQR filtering**: Samples outside Q1โˆ’1.5ร—IQR and Q3+1.5ร—IQR are discarded +4. **Median**: The median of remaining samples is used for comparison + +### Baseline Management + +Baselines are stored in `baselines.json` in this directory. Each scenario has: + +```json +{ + "cold-startup-time": { + "wallClockMs": 1234.5, + "cpuTotalUs": 567890, + "eventLoopDelayP99Ms": 12.3, + "timestamp": "2026-04-08T..." + } +} +``` + +Tests fail if the measured value exceeds `baseline ร— 1.15` (15% tolerance). + +To recalibrate after intentional changes: + +```bash +npm run test:perf:update-baselines +# then commit baselines.json +``` + +### Report Output + +After all tests, the harness prints an ASCII summary: + +``` +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + PERFORMANCE TEST REPORT +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +cold-startup-time: 1234.5 ms (Baseline: 1200.0 ms, Delta: +2.9%) โœ… +idle-cpu-usage: 2.1 % (Baseline: 2.0 %, Delta: +5.0%) โœ… +skill-loading-time: 1567.8 ms (Baseline: 1500.0 ms, Delta: +4.5%) โœ… +``` + +## Architecture + +``` +perf-tests/ +โ”œโ”€โ”€ README.md โ† you are here +โ”œโ”€โ”€ baselines.json โ† committed baseline values +โ”œโ”€โ”€ globalSetup.ts โ† test environment setup +โ”œโ”€โ”€ perf-usage.test.ts โ† test scenarios +โ”œโ”€โ”€ perf.*.responses โ† fake API responses per scenario +โ”œโ”€โ”€ tsconfig.json โ† TypeScript config +โ””โ”€โ”€ vitest.config.ts โ† vitest config (serial, isolated) + +packages/test-utils/src/ +โ”œโ”€โ”€ perf-test-harness.ts โ† PerfTestHarness class +โ””โ”€โ”€ index.ts โ† re-exports +``` + +## CI Integration + +These tests are **excluded from `preflight`** and designed for nightly CI: + +```yaml +- name: Performance regression tests + run: npm run test:perf +``` + +## Adding a New Scenario + +1. Add a fake response file: `perf..responses` +2. Add a test case in `perf-usage.test.ts` using `harness.runScenario()` +3. Run `npm run test:perf:update-baselines` to establish initial baseline +4. Commit the updated `baselines.json` diff --git a/perf-tests/baselines.json b/perf-tests/baselines.json new file mode 100644 index 0000000000000000000000000000000000000000..caf92bedb65c45c8de31f7c9cc46b04060bc553c --- /dev/null +++ b/perf-tests/baselines.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "updatedAt": "2026-04-14T14:04:02.662Z", + "scenarios": { + "cold-startup-time": { + "wallClockMs": 927.6, + "cpuTotalUs": 1470, + "timestamp": "2026-04-08T22:27:54.871Z" + }, + "idle-cpu-usage": { + "wallClockMs": 5000.5, + "cpuTotalUs": 12157, + "timestamp": "2026-04-08T22:28:19.098Z" + }, + "asian-language-conv": { + "wallClockMs": 2315.1, + "cpuTotalUs": 6283, + "timestamp": "2026-04-14T15:22:56.133Z" + }, + "skill-loading-time": { + "wallClockMs": 930.1, + "cpuTotalUs": 1323, + "timestamp": "2026-04-08T22:28:23.290Z" + }, + "high-volume-shell-output": { + "wallClockMs": 1119.9, + "cpuTotalUs": 2100, + "timestamp": "2026-04-09T02:30:22.000Z" + }, + "long-conversation-resume": { + "wallClockMs": 4212.5, + "cpuTotalUs": 351393, + "timestamp": "2026-04-14T14:02:53.268Z" + }, + "long-conversation-typing": { + "wallClockMs": 113.7, + "cpuTotalUs": 3304, + "timestamp": "2026-04-14T14:03:12.525Z" + }, + "long-conversation-execution": { + "wallClockMs": 248.7, + "cpuTotalUs": 3825, + "timestamp": "2026-04-14T14:03:28.575Z" + }, + "long-conversation-terminal-scrolling": { + "wallClockMs": 362.4, + "cpuTotalUs": 12755860, + "timestamp": "2026-04-14T14:03:45.687Z" + }, + "long-conversation-alternate-scrolling": { + "wallClockMs": 362.4, + "cpuTotalUs": 12755860, + "timestamp": "2026-04-14T14:04:02.662Z" + } + } +} diff --git a/perf-tests/globalSetup.ts b/perf-tests/globalSetup.ts new file mode 100644 index 0000000000000000000000000000000000000000..77447bd2ba2fe0c290395cd6ee37d02cc62cf10b --- /dev/null +++ b/perf-tests/globalSetup.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mkdir, readdir, rm } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js'; +import { isolateTestEnv } from '../packages/test-utils/src/env-setup.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); +const perfTestsDir = join(rootDir, '.perf-tests'); +const KEEP_RUNS_COUNT = 5; +let runDir = ''; + +export async function setup() { + runDir = join(perfTestsDir, `${Date.now()}`); + await mkdir(runDir, { recursive: true }); + + // Isolate environment variables + isolateTestEnv(runDir); + + // Download ripgrep to avoid race conditions + const available = await canUseRipgrep(); + if (!available) { + throw new Error('Failed to download ripgrep binary'); + } + + // Clean up old test runs, keeping the latest few for debugging + try { + const testRuns = await readdir(perfTestsDir); + if (testRuns.length > KEEP_RUNS_COUNT) { + const oldRuns = testRuns + .sort() + .slice(0, testRuns.length - KEEP_RUNS_COUNT); + await Promise.all( + oldRuns.map((oldRun) => + rm(join(perfTestsDir, oldRun), { + recursive: true, + force: true, + }), + ), + ); + } + } catch (e) { + console.error('Error cleaning up old perf test runs:', e); + } + + process.env['INTEGRATION_TEST_FILE_DIR'] = runDir; + process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false'; + + console.log(`\nPerf test output directory: ${runDir}`); +} + +export async function teardown() { + // Cleanup unless KEEP_OUTPUT is set + if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { + try { + await rm(runDir, { recursive: true, force: true }); + } catch (e) { + console.warn('Failed to clean up perf test directory:', e); + } + } +} diff --git a/perf-tests/perf-usage.test.ts b/perf-tests/perf-usage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a100382f48e0907286526e25df327e53d873563d --- /dev/null +++ b/perf-tests/perf-usage.test.ts @@ -0,0 +1,660 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, beforeAll, afterAll } from 'vitest'; +import { + TestRig, + PerfTestHarness, + type PerfSnapshot, +} from '@google/gemini-cli-test-utils'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + existsSync, + readFileSync, + mkdirSync, + copyFileSync, + writeFileSync, +} from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BASELINES_PATH = join(__dirname, 'baselines.json'); +const UPDATE_BASELINES = process.env['UPDATE_PERF_BASELINES'] === 'true'; +const TOLERANCE_PERCENT = 15; + +// Use fewer samples locally for faster iteration, more in CI +const SAMPLE_COUNT = process.env['CI'] ? 5 : 3; +const WARMUP_COUNT = 1; + +describe('CPU Performance Tests', () => { + let harness: PerfTestHarness; + + beforeAll(() => { + harness = new PerfTestHarness({ + baselinesPath: BASELINES_PATH, + defaultTolerancePercent: TOLERANCE_PERCENT, + sampleCount: SAMPLE_COUNT, + warmupCount: WARMUP_COUNT, + }); + }); + + afterAll(async () => { + // Generate the summary report after all tests + await harness.generateReport(); + }, 30000); + + it('cold-startup-time: startup completes within baseline', async () => { + const result = await harness.runScenario('cold-startup-time', async () => { + const rig = new TestRig(); + try { + rig.setup('perf-cold-startup', { + fakeResponsesPath: join(__dirname, 'perf.cold-startup.responses'), + }); + + return await harness.measure('cold-startup', async () => { + await rig.run({ + args: ['hello'], + timeout: 120000, + env: { GEMINI_API_KEY: 'fake-perf-test-key' }, + }); + }); + } finally { + await rig.cleanup(); + } + }); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('idle-cpu-usage: CPU stays low when idle', async () => { + const IDLE_OBSERVATION_MS = 5000; + + const result = await harness.runScenario('idle-cpu-usage', async () => { + const rig = new TestRig(); + try { + rig.setup('perf-idle-cpu', { + fakeResponsesPath: join(__dirname, 'perf.idle-cpu.responses'), + }); + + // First, run a prompt to get the CLI into idle state + await rig.run({ + args: ['hello'], + timeout: 120000, + env: { GEMINI_API_KEY: 'fake-perf-test-key' }, + }); + + // Now measure CPU during idle period in the test process + return await harness.measureWithEventLoop('idle-cpu', async () => { + // Simulate idle period โ€” just wait + const { setTimeout: sleep } = await import('node:timers/promises'); + await sleep(IDLE_OBSERVATION_MS); + }); + } finally { + await rig.cleanup(); + } + }); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('asian-language-conv: verify perf is acceptable ', async () => { + const result = await harness.runScenario( + 'asian-language-conv', + async () => { + const rig = new TestRig(); + try { + rig.setup('perf-asian-language', { + fakeResponsesPath: join(__dirname, 'perf.asian-language.responses'), + }); + + return await harness.measure('asian-language', async () => { + await rig.run({ + args: ['ๅ—จ'], + timeout: 120000, + env: { GEMINI_API_KEY: 'fake-perf-test-key' }, + }); + }); + } finally { + await rig.cleanup(); + } + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('skill-loading-time: startup with many skills within baseline', async () => { + const SKILL_COUNT = 20; + + const result = await harness.runScenario('skill-loading-time', async () => { + const rig = new TestRig(); + try { + rig.setup('perf-skill-loading', { + fakeResponsesPath: join(__dirname, 'perf.skill-loading.responses'), + }); + + // Create many skill directories with SKILL.md files + for (let i = 0; i < SKILL_COUNT; i++) { + const skillDir = `.gemini/skills/perf-skill-${i}`; + rig.mkdir(skillDir); + rig.createFile( + `${skillDir}/SKILL.md`, + [ + '---', + `name: perf-skill-${i}`, + `description: Performance test skill number ${i}`, + `activation: manual`, + '---', + '', + `# Performance Test Skill ${i}`, + '', + `This is a test skill for measuring skill loading performance.`, + `It contains some content to simulate real-world skill files.`, + '', + `## Usage`, + '', + `Use this skill by activating it with @perf-skill-${i}.`, + ].join('\n'), + ); + } + + return await harness.measure('skill-loading', async () => { + await rig.run({ + args: ['hello'], + timeout: 120000, + env: { GEMINI_API_KEY: 'fake-perf-test-key' }, + }); + }); + } finally { + await rig.cleanup(); + } + }); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('high-volume-shell-output: handles large output efficiently', async () => { + const result = await harness.runScenario( + 'high-volume-shell-output', + async () => { + const rig = new TestRig(); + try { + rig.setup('perf-high-volume-output', { + fakeResponsesPath: join(__dirname, 'perf.high-volume.responses'), + }); + + const snapshot = await harness.measureWithEventLoop( + 'high-volume-output', + async () => { + await rig.run({ + args: ['Generate 1M lines of output'], + timeout: 120000, + env: { + GEMINI_API_KEY: 'fake-perf-test-key', + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_MEMORY_MONITOR_INTERVAL: '500', + GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true', + DEBUG: 'true', + }, + }); + }, + ); + + // Query CLI's own performance metrics from telemetry logs + await rig.waitForTelemetryReady(); + + // Debug: Read and log the telemetry file content + try { + const logFilePath = join(rig.homeDir!, 'telemetry.log'); + if (existsSync(logFilePath)) { + const content = readFileSync(logFilePath, 'utf-8'); + console.log(` Telemetry Log Content:\n`, content); + } else { + console.log(` Telemetry log file not found at: ${logFilePath}`); + } + } catch (e) { + console.error(` Failed to read telemetry log:`, e); + } + + const memoryMetric = rig.readMetric('memory.usage'); + const cpuMetric = rig.readMetric('cpu.usage'); + const toolLatencyMetric = rig.readMetric('tool.call.latency'); + const eventLoopMetric = rig.readMetric('event_loop.delay'); + + if (memoryMetric) { + console.log( + ` CLI Memory Metric found:`, + JSON.stringify(memoryMetric), + ); + } + if (cpuMetric) { + console.log(` CLI CPU Metric found:`, JSON.stringify(cpuMetric)); + } + if (toolLatencyMetric) { + console.log( + ` CLI Tool Latency Metric found:`, + JSON.stringify(toolLatencyMetric), + ); + } + const logs = rig.readTelemetryLogs(); + console.log(` Total telemetry log entries: ${logs.length}`); + for (const logData of logs) { + if (logData.scopeMetrics) { + for (const scopeMetric of logData.scopeMetrics) { + for (const metric of scopeMetric.metrics) { + if (metric.descriptor.name.includes('event_loop')) { + console.log( + ` Found event_loop metric in log:`, + metric.descriptor.name, + ); + } + } + } + } + } + + if (eventLoopMetric) { + console.log( + ` CLI Event Loop Metric found:`, + JSON.stringify(eventLoopMetric), + ); + + const findValue = (percentile: string) => { + const dp = eventLoopMetric.dataPoints.find( + (p) => p.attributes?.['percentile'] === percentile, + ); + return dp?.value?.min; + }; + + snapshot.childEventLoopDelayP50Ms = findValue('p50'); + snapshot.childEventLoopDelayP95Ms = findValue('p95'); + snapshot.childEventLoopDelayMaxMs = findValue('max'); + } + + return snapshot; + } finally { + await rig.cleanup(); + } + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + describe('long-conversation', () => { + let rig: TestRig; + const identifier = 'perf-long-conversation'; + const SESSION_ID = + 'anonymous_unique_id_577296e0eee5afecdcec05d11838e0cd1a851cd97a28119a4a876b11'; + const LARGE_CHAT_SOURCE = join( + __dirname, + '..', + 'memory-tests', + 'large-chat-session.json', + ); + + beforeAll(async () => { + if (!existsSync(LARGE_CHAT_SOURCE)) { + throw new Error( + `Performance test fixture missing: ${LARGE_CHAT_SOURCE}.`, + ); + } + + rig = new TestRig(); + rig.setup(identifier, { + fakeResponsesPath: join(__dirname, 'perf.long-chat.responses'), + }); + + const geminiDir = join(rig.homeDir!, '.gemini'); + const projectTempDir = join(geminiDir, 'tmp', identifier); + const targetChatsDir = join(projectTempDir, 'chats'); + + mkdirSync(targetChatsDir, { recursive: true }); + writeFileSync( + join(geminiDir, 'projects.json'), + JSON.stringify({ + projects: { [rig.testDir!]: identifier }, + }), + ); + writeFileSync(join(projectTempDir, '.project_root'), rig.testDir!); + copyFileSync( + LARGE_CHAT_SOURCE, + join(targetChatsDir, `session-${SESSION_ID}.json`), + ); + }); + + afterAll(async () => { + await rig.cleanup(); + }); + + it('session-load: resume a 60MB chat history', async () => { + const result = await harness.runScenario( + 'long-conversation-resume', + async () => { + const snapshot = await harness.measureWithEventLoop( + 'resume', + async () => { + const run = await rig.runInteractive({ + args: ['--resume', 'latest'], + env: { + GEMINI_API_KEY: 'fake-perf-test-key', + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_MEMORY_MONITOR_INTERVAL: '500', + GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true', + DEBUG: 'true', + }, + }); + await run.kill(); + }, + ); + return snapshot; + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('typing: latency when typing into a large session', async () => { + const result = await harness.runScenario( + 'long-conversation-typing', + async () => { + const run = await rig.runInteractive({ + args: ['--resume', 'latest'], + env: { + GEMINI_API_KEY: 'fake-perf-test-key', + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_MEMORY_MONITOR_INTERVAL: '500', + GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true', + DEBUG: 'true', + }, + }); + + const snapshot = await harness.measureWithEventLoop( + 'typing', + async () => { + // On average, the expected latency per key is under 30ms. + for (const char of 'Hello') { + await run.type(char); + } + }, + ); + + await run.kill(); + return snapshot; + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('execution: response latency for a simple shell command', async () => { + const result = await harness.runScenario( + 'long-conversation-execution', + async () => { + const run = await rig.runInteractive({ + args: ['--resume', 'latest'], + env: { + GEMINI_API_KEY: 'fake-perf-test-key', + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_MEMORY_MONITOR_INTERVAL: '500', + GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true', + DEBUG: 'true', + }, + }); + + await run.expectText('Type your message'); + + const snapshot = await harness.measureWithEventLoop( + 'execution', + async () => { + await run.sendKeys('!echo hi\r'); + await run.expectText('hi'); + }, + ); + + await run.kill(); + return snapshot; + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('terminal-scrolling: latency when scrolling a large terminal buffer', async () => { + const result = await harness.runScenario( + 'long-conversation-terminal-scrolling', + async () => { + // Enable terminalBuffer to intentionally test CLI scrolling logic + const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json'); + writeFileSync( + settingsPath, + JSON.stringify({ + security: { folderTrust: { enabled: false } }, + ui: { terminalBuffer: true }, + }), + ); + + const run = await rig.runInteractive({ + args: ['--resume', 'latest'], + env: { + GEMINI_API_KEY: 'fake-perf-test-key', + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_MEMORY_MONITOR_INTERVAL: '500', + GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true', + DEBUG: 'true', + }, + }); + + await run.expectText('Type your message'); + + for (let i = 0; i < 5; i++) { + await run.sendKeys('\u001b[5~'); // PageUp + } + + // Scroll to the very top + await run.sendKeys('\u001b[H'); // Home + // Verify top line of chat is visible. + await run.expectText('Authenticated with'); + + for (let i = 0; i < 5; i++) { + await run.sendKeys('\u001b[6~'); // PageDown + } + + await rig.waitForTelemetryReady(); + await run.kill(); + + const eventLoopMetric = rig.readMetric('event_loop.delay'); + const cpuMetric = rig.readMetric('cpu.usage'); + + let p50Ms = 0; + let p95Ms = 0; + let maxMs = 0; + if (eventLoopMetric) { + const dataPoints = eventLoopMetric.dataPoints; + const p50Data = dataPoints.find( + (dp) => dp.attributes?.['percentile'] === 'p50', + ); + const p95Data = dataPoints.find( + (dp) => dp.attributes?.['percentile'] === 'p95', + ); + const maxData = dataPoints.find( + (dp) => dp.attributes?.['percentile'] === 'max', + ); + + if (p50Data?.value?.sum) p50Ms = p50Data.value.sum; + if (p95Data?.value?.sum) p95Ms = p95Data.value.sum; + if (maxData?.value?.sum) maxMs = maxData.value.sum; + } + + let cpuTotalUs = 0; + if (cpuMetric) { + const dataPoints = cpuMetric.dataPoints; + for (const dp of dataPoints) { + if (dp.value?.sum && dp.value.sum > 0) { + cpuTotalUs += dp.value.sum; + } + } + } + const cpuUserUs = cpuTotalUs; + const cpuSystemUs = 0; + + const snapshot: PerfSnapshot = { + timestamp: Date.now(), + label: 'scrolling', + wallClockMs: Math.round(p50Ms * 10) / 10, + cpuTotalUs, + cpuUserUs, + cpuSystemUs, + eventLoopDelayP50Ms: p50Ms, + eventLoopDelayP95Ms: p95Ms, + eventLoopDelayMaxMs: maxMs, + }; + + return snapshot; + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + + it('alternate-scrolling: latency when scrolling a large alternate buffer', async () => { + const result = await harness.runScenario( + 'long-conversation-alternate-scrolling', + async () => { + // Enable useAlternateBuffer to intentionally test CLI scrolling logic + const settingsPath = join(rig.homeDir!, '.gemini', 'settings.json'); + writeFileSync( + settingsPath, + JSON.stringify({ + security: { folderTrust: { enabled: false } }, + ui: { useAlternateBuffer: true }, + }), + ); + + const run = await rig.runInteractive({ + args: ['--resume', 'latest'], + env: { + GEMINI_API_KEY: 'fake-perf-test-key', + GEMINI_TELEMETRY_ENABLED: 'true', + GEMINI_MEMORY_MONITOR_INTERVAL: '500', + GEMINI_EVENT_LOOP_MONITOR_ENABLED: 'true', + DEBUG: 'true', + }, + }); + + await run.expectText('Type your message'); + + for (let i = 0; i < 5; i++) { + await run.sendKeys('\u001b[5~'); // PageUp + } + + // Scroll to the very top + await run.sendKeys('\u001b[H'); // Home + // Verify top line of chat is visible. + await run.expectText('Authenticated with'); + + for (let i = 0; i < 5; i++) { + await run.sendKeys('\u001b[6~'); // PageDown + } + + await rig.waitForTelemetryReady(); + await run.kill(); + + const eventLoopMetric = rig.readMetric('event_loop.delay'); + const cpuMetric = rig.readMetric('cpu.usage'); + + let p50Ms = 0; + let p95Ms = 0; + let maxMs = 0; + if (eventLoopMetric) { + const dataPoints = eventLoopMetric.dataPoints; + const p50Data = dataPoints.find( + (dp) => dp.attributes?.['percentile'] === 'p50', + ); + const p95Data = dataPoints.find( + (dp) => dp.attributes?.['percentile'] === 'p95', + ); + const maxData = dataPoints.find( + (dp) => dp.attributes?.['percentile'] === 'max', + ); + + if (p50Data?.value?.sum) p50Ms = p50Data.value.sum; + if (p95Data?.value?.sum) p95Ms = p95Data.value.sum; + if (maxData?.value?.sum) maxMs = maxData.value.sum; + } + + let cpuTotalUs = 0; + if (cpuMetric) { + const dataPoints = cpuMetric.dataPoints; + for (const dp of dataPoints) { + if (dp.value?.sum && dp.value.sum > 0) { + cpuTotalUs += dp.value.sum; + } + } + } + const cpuUserUs = cpuTotalUs; + const cpuSystemUs = 0; + + const snapshot: PerfSnapshot = { + timestamp: Date.now(), + label: 'scrolling', + wallClockMs: Math.round(p50Ms * 10) / 10, + cpuTotalUs, + cpuUserUs, + cpuSystemUs, + eventLoopDelayP50Ms: p50Ms, + eventLoopDelayP95Ms: p95Ms, + eventLoopDelayMaxMs: maxMs, + }; + + return snapshot; + }, + ); + + if (UPDATE_BASELINES) { + harness.updateScenarioBaseline(result); + } else { + harness.assertWithinBaseline(result); + } + }); + }); +}); diff --git a/perf-tests/perf.asian-language.responses b/perf-tests/perf.asian-language.responses new file mode 100644 index 0000000000000000000000000000000000000000..8f3c71775bd730f8053784d333260abd6a734f77 --- /dev/null +++ b/perf-tests/perf.asian-language.responses @@ -0,0 +1,2 @@ +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"ไฝ ๅฅฝ๏ผๆˆ‘ๆ˜ฏ Gemini CLI๏ผŒไฝ ็š„ AI ็ผ–็จ‹ๅŠฉๆ‰‹"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":20648,"candidatesTokenCount":12,"totalTokenCount":20769,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} diff --git a/perf-tests/perf.cold-startup.responses b/perf-tests/perf.cold-startup.responses new file mode 100644 index 0000000000000000000000000000000000000000..7a5703e3d26142ec2139228a25e51b785b356888 --- /dev/null +++ b/perf-tests/perf.cold-startup.responses @@ -0,0 +1,2 @@ +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help. What would you like to work on?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":12,"totalTokenCount":17,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} diff --git a/perf-tests/perf.high-volume.responses b/perf-tests/perf.high-volume.responses new file mode 100644 index 0000000000000000000000000000000000000000..74f5972db906059e70c7d8dfff79bd38d9afde99 --- /dev/null +++ b/perf-tests/perf.high-volume.responses @@ -0,0 +1,3 @@ +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run_shell_command","args":{"command":"yes | head -n 1000000"}}}],"role":"model"},"finishReason":"STOP","index":0}]}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have generated 1M lines of output."}],"role":"model"},"finishReason":"STOP","index":0}]}]} diff --git a/perf-tests/perf.idle-cpu.responses b/perf-tests/perf.idle-cpu.responses new file mode 100644 index 0000000000000000000000000000000000000000..a0d05086d27dc9cfbdd695cf81016109a0b0c08d --- /dev/null +++ b/perf-tests/perf.idle-cpu.responses @@ -0,0 +1,2 @@ +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":8,"totalTokenCount":13,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} diff --git a/perf-tests/perf.long-chat.responses b/perf-tests/perf.long-chat.responses new file mode 100644 index 0000000000000000000000000000000000000000..7cf057e5a4bf55ba3bede40ab28f00dd43ba9014 --- /dev/null +++ b/perf-tests/perf.long-chat.responses @@ -0,0 +1,4 @@ +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"complexity_reasoning\":\"simple\",\"complexity_score\":1}"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am a large conversation model response."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"candidatesTokenCount":10,"promptTokenCount":20,"totalTokenCount":30}}]} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"{\"originalSummary\":\"large chat summary\",\"events\":[]}"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"countTokens","response":{"totalTokens":100}} diff --git a/perf-tests/perf.skill-loading.responses b/perf-tests/perf.skill-loading.responses new file mode 100644 index 0000000000000000000000000000000000000000..eb6c96fe9c2960f0f35f27c4b9d726c7faadf0f6 --- /dev/null +++ b/perf-tests/perf.skill-loading.responses @@ -0,0 +1,2 @@ +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to assist you with your project."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":10,"totalTokenCount":15,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} diff --git a/perf-tests/tsconfig.json b/perf-tests/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..7f2c199703ed1a2ed8f1407bd4ed670b3f2962e4 --- /dev/null +++ b/perf-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "allowJs": true + }, + "include": ["**/*.ts"], + "references": [ + { "path": "../packages/core" }, + { "path": "../packages/test-utils" } + ] +} diff --git a/perf-tests/vitest.config.ts b/perf-tests/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9baeec0bfe79422075c3d14639d7785a0fd5e8a --- /dev/null +++ b/perf-tests/vitest.config.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 600000, // 10 minutes โ€” performance profiling needs time for multiple samples + globalSetup: './globalSetup.ts', + reporters: ['default'], + include: ['**/*.test.ts'], + retry: 0, // No retries โ€” noise is handled by IQR filtering and tolerance + fileParallelism: false, // Must run serially to avoid CPU contention + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, // Single process for accurate per-test CPU readings + }, + }, + env: { + GEMINI_TEST_TYPE: 'perf', + }, + }, +}); diff --git a/scripts/aggregate_evals.js b/scripts/aggregate_evals.js new file mode 100644 index 0000000000000000000000000000000000000000..263660a25a10ed80c339defbdd0d2f542547e536 --- /dev/null +++ b/scripts/aggregate_evals.js @@ -0,0 +1,253 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import os from 'node:os'; + +const artifactsDir = process.argv[2] || '.'; +const MAX_HISTORY = 10; + +// Find all report.json files recursively +function findReports(dir) { + const reports = []; + if (!fs.existsSync(dir)) return reports; + + const files = fs.readdirSync(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + reports.push(...findReports(fullPath)); + } else if (file === 'report.json') { + reports.push(fullPath); + } + } + return reports; +} + +function getModelFromPath(reportPath) { + const parts = reportPath.split(path.sep); + // Find the part that starts with 'eval-logs-' + const artifactDir = parts.find((p) => p.startsWith('eval-logs-')); + if (!artifactDir) return 'unknown'; + + const matchNew = artifactDir.match(/^eval-logs-(.+)-(\d+)$/); + if (matchNew) return matchNew[1]; + + const matchOld = artifactDir.match(/^eval-logs-(\d+)$/); + if (matchOld) return 'gemini-2.5-pro'; // Legacy default + + return 'unknown'; +} + +function getStats(reports) { + // Structure: { [model]: { [testName]: { passed, failed, total } } } + const statsByModel = {}; + + for (const reportPath of reports) { + try { + const model = getModelFromPath(reportPath); + if (!statsByModel[model]) { + statsByModel[model] = {}; + } + const testStats = statsByModel[model]; + + const content = fs.readFileSync(reportPath, 'utf-8'); + const json = JSON.parse(content); + + for (const testResult of json.testResults) { + for (const assertion of testResult.assertionResults) { + const name = assertion.title; + if (!testStats[name]) { + testStats[name] = { passed: 0, failed: 0, total: 0 }; + } + testStats[name].total++; + if (assertion.status === 'passed') { + testStats[name].passed++; + } else { + testStats[name].failed++; + } + } + } + } catch (error) { + console.error(`Error processing report at ${reportPath}:`, error); + } + } + return statsByModel; +} + +function fetchHistoricalData() { + const history = []; + + try { + // Determine branch + const branch = 'main'; + + // Get recent runs + const cmd = `gh run list --workflow evals-nightly.yml --branch "${branch}" --limit ${ + MAX_HISTORY + 5 + } --json databaseId,createdAt,url,displayTitle,status,conclusion`; + const runsJson = execSync(cmd, { encoding: 'utf-8' }); + let runs = JSON.parse(runsJson); + + // Filter out current run + const currentRunId = process.env.GITHUB_RUN_ID; + if (currentRunId) { + runs = runs.filter((r) => r.databaseId.toString() !== currentRunId); + } + + // Filter for runs that likely have artifacts (completed) and take top N + // We accept 'failure' too because we want to see stats. + runs = runs.filter((r) => r.status === 'completed').slice(0, MAX_HISTORY); + + // Fetch artifacts for each run + for (const run of runs) { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), `gemini-evals-${run.databaseId}-`), + ); + try { + // Download report.json files. + // The artifacts are named 'eval-logs-X' or 'eval-logs-MODEL-X'. + // We use -p to match pattern. + execSync( + `gh run download ${run.databaseId} -p "eval-logs-*" -D "${tmpDir}"`, + { stdio: 'ignore' }, + ); + + const runReports = findReports(tmpDir); + if (runReports.length > 0) { + history.push({ + run, + stats: getStats(runReports), // Now returns stats grouped by model + }); + } + } catch (error) { + console.error( + `Failed to download or process artifacts for run ${run.databaseId}:`, + error, + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } + } catch (error) { + console.error('Failed to fetch historical data:', error); + } + + return history; +} + +function generateMarkdown(currentStatsByModel, history) { + console.log('### Evals Nightly Summary\n'); + console.log( + 'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n', + ); + + // Reverse history to show oldest first + const reversedHistory = [...history].reverse(); + + const models = Object.keys(currentStatsByModel).sort(); + + const getPassRate = (statsForModel) => { + if (!statsForModel) return '-'; + const totalStats = Object.values(statsForModel).reduce( + (acc, stats) => { + acc.passed += stats.passed; + acc.total += stats.total; + return acc; + }, + { passed: 0, total: 0 }, + ); + return totalStats.total > 0 + ? ((totalStats.passed / totalStats.total) * 100).toFixed(1) + '%' + : '-'; + }; + + for (const model of models) { + const currentStats = currentStatsByModel[model]; + const totalPassRate = getPassRate(currentStats); + + console.log(`#### Model: ${model}`); + console.log(`**Total Pass Rate: ${totalPassRate}**\n`); + + // Header + let header = '| Test Name |'; + let separator = '| :--- |'; + let passRateRow = '| **Overall Pass Rate** |'; + + for (const item of reversedHistory) { + header += ` [${item.run.databaseId}](${item.run.url}) |`; + separator += ' :---: |'; + passRateRow += ` **${getPassRate(item.stats[model])}** |`; + } + + // Add Current column last + header += ' Current |'; + separator += ' :---: |'; + passRateRow += ` **${totalPassRate}** |`; + + console.log(header); + console.log(separator); + console.log(passRateRow); + + // Collect all test names for this model + const allTestNames = new Set(Object.keys(currentStats)); + for (const item of reversedHistory) { + if (item.stats[model]) { + Object.keys(item.stats[model]).forEach((name) => + allTestNames.add(name), + ); + } + } + + for (const name of Array.from(allTestNames).sort()) { + const searchUrl = `https://github.com/search?q=repo%3Agoogle-gemini%2Fgemini-cli%20%22${encodeURIComponent(name)}%22&type=code`; + let row = `| [${name}](${searchUrl}) |`; + + // History + for (const item of reversedHistory) { + const stat = item.stats[model] ? item.stats[model][name] : null; + if (stat) { + const passRate = ((stat.passed / stat.total) * 100).toFixed(0) + '%'; + row += ` ${passRate} |`; + } else { + row += ' - |'; + } + } + + // Current + const curr = currentStats[name]; + if (curr) { + const passRate = ((curr.passed / curr.total) * 100).toFixed(0) + '%'; + row += ` ${passRate} |`; + } else { + row += ' - |'; + } + + console.log(row); + } + console.log('\n'); + } +} + +// --- Main --- + +const currentReports = findReports(artifactsDir); +if (currentReports.length === 0) { + console.log('No reports found.'); + // We don't exit here because we might still want to see history if available, + // but practically if current has no reports, something is wrong. + // Sticking to original behavior roughly, but maybe we can continue. + process.exit(0); +} + +const currentStats = getStats(currentReports); +const history = fetchHistoricalData(); +generateMarkdown(currentStats, history); diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000000000000000000000000000000000000..b5ef6f672b6c009e75a58458d4702b7f11239cea --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +// npm install if node_modules was removed (e.g. via npm run clean or scripts/clean.js) +if (!existsSync(join(root, 'node_modules'))) { + execSync('npm install', { stdio: 'inherit', cwd: root }); +} + +// build all workspaces/packages +execSync('npm run generate', { stdio: 'inherit', cwd: root }); + +if (process.env.CI) { + console.log('CI environment detected. Building workspaces sequentially...'); + execSync('npm run build --workspaces', { stdio: 'inherit', cwd: root }); +} else { + // Build core first because everyone depends on it + console.log('Building @google/gemini-cli-core...'); + execSync('npm run build -w @google/gemini-cli-core', { + stdio: 'inherit', + cwd: root, + }); + + // Build the rest in parallel + console.log('Building other workspaces in parallel...'); + const workspaceInfo = JSON.parse( + execSync('npm query .workspace --json', { cwd: root, encoding: 'utf-8' }), + ); + const parallelWorkspaces = workspaceInfo + .map((w) => w.name) + .filter((name) => name !== '@google/gemini-cli-core'); + + execSync( + `npx --no-install npm-run-all --parallel ${parallelWorkspaces.map((w) => `"build -w ${w}"`).join(' ')}`, + { stdio: 'inherit', cwd: root }, + ); +} + +// also build container image if sandboxing is enabled +// skip (-s) npm install + build since we did that above +try { + execSync('node scripts/sandbox_command.js -q', { + stdio: 'inherit', + cwd: root, + }); + if ( + process.env.BUILD_SANDBOX === '1' || + process.env.BUILD_SANDBOX === 'true' + ) { + execSync('node scripts/build_sandbox.js -s', { + stdio: 'inherit', + cwd: root, + }); + } +} catch { + // ignore +} diff --git a/scripts/build_binary.js b/scripts/build_binary.js new file mode 100644 index 0000000000000000000000000000000000000000..c2e0c8490f9f0a80f8fa8768b7c704f5feb0fb26 --- /dev/null +++ b/scripts/build_binary.js @@ -0,0 +1,515 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawnSync } from 'node:child_process'; +import { + cpSync, + rmSync, + mkdirSync, + existsSync, + copyFileSync, + writeFileSync, + readFileSync, + chmodSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; +import { globSync } from 'glob'; +import { createHash } from 'node:crypto'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const distDir = join(root, 'dist'); +const bundleDir = join(root, 'bundle'); +const stagingDir = join(bundleDir, 'native_modules'); +const seaConfigPath = join(root, 'sea-config.json'); +const manifestPath = join(bundleDir, 'manifest.json'); +const entitlementsPath = join(root, 'scripts/entitlements.plist'); + +// --- Helper Functions --- + +/** + * Safely executes a command using spawnSync. + * @param {string} command + * @param {string[]} args + * @param {object} options + */ +function runCommand(command, args, options = {}) { + let finalCommand = command; + let useShell = options.shell || false; + + // On Windows, npm/npx are batch files and need a shell + if ( + process.platform === 'win32' && + (command === 'npm' || command === 'npx') + ) { + finalCommand = `${command}.cmd`; + useShell = true; + } + + const finalOptions = { + stdio: 'inherit', + cwd: root, + shell: useShell, + ...options, + }; + + const result = spawnSync(finalCommand, args, finalOptions); + + if (result.status !== 0) { + if (result.error) { + throw result.error; + } + throw new Error( + `Command failed with exit code ${result.status}: ${command}`, + ); + } + + return result; +} + +/** + * Removes existing digital signatures from a binary. + * @param {string} filePath + */ +function removeSignature(filePath) { + console.log(`Removing signature from ${filePath}...`); + const platform = process.platform; + try { + if (platform === 'darwin') { + spawnSync('codesign', ['--remove-signature', filePath], { + stdio: 'ignore', + }); + } else if (platform === 'win32') { + spawnSync('signtool', ['remove', '/s', filePath], { + stdio: 'ignore', + }); + } + } catch { + // Best effort: Ignore failures + } +} + +/** + * Signs a binary using hardcoded tools for the platform. + * @param {string} filePath + */ +function signFile(filePath) { + if (process.env.SKIP_SIGNING === 'true') { + console.log(`Skipping signing for ${filePath} (SKIP_SIGNING=true)`); + return; + } + + const platform = process.platform; + + if (platform === 'darwin') { + const identity = process.env.APPLE_IDENTITY || '-'; + console.log(`Signing ${filePath} (Identity: ${identity})...`); + + const args = [ + '--sign', + identity, + '--force', + '--timestamp', + '--options', + 'runtime', + ]; + + if (existsSync(entitlementsPath)) { + args.push('--entitlements', entitlementsPath); + } + + args.push(filePath); + + runCommand('codesign', args); + } else if (platform === 'win32') { + const args = ['sign']; + + if (process.env.WINDOWS_PFX_FILE && process.env.WINDOWS_PFX_PASSWORD) { + args.push( + '/f', + process.env.WINDOWS_PFX_FILE, + '/p', + process.env.WINDOWS_PFX_PASSWORD, + ); + } else { + args.push('/a'); + } + + args.push( + '/fd', + 'SHA256', + '/td', + 'SHA256', + '/tr', + 'http://timestamp.digicert.com', + filePath, + ); + + console.log(`Signing ${filePath}...`); + try { + runCommand('signtool', args, { stdio: 'pipe' }); + } catch (e) { + let msg = e.message; + if (process.env.WINDOWS_PFX_PASSWORD) { + msg = msg.replaceAll(process.env.WINDOWS_PFX_PASSWORD, '******'); + } + throw new Error(msg); + } + } else if (platform === 'linux') { + console.log(`Skipping signing for ${filePath} on Linux.`); + } +} + +console.log('Build Binary Script Started...'); + +// 1. Clean dist +if (existsSync(distDir)) { + console.log('Cleaning dist directory...'); + rmSync(distDir, { recursive: true, force: true }); +} +mkdirSync(distDir, { recursive: true }); + +// 2. Build Bundle +console.log('Running npm clean, install, and bundle...'); +try { + runCommand('npm', ['run', 'clean']); + runCommand('npm', ['install']); + runCommand('npm', ['run', 'bundle']); +} catch (e) { + console.error('Build step failed:', e.message); + process.exit(1); +} + +// 2b. Copy host-platform ripgrep binary into the bundle for the SEA. +// (npm tarballs omit these to stay under the registry upload limit.) +const ripgrepVendorSrc = join(root, 'packages/core/vendor/ripgrep'); +const ripgrepVendorDest = join(bundleDir, 'vendor', 'ripgrep'); +if (existsSync(ripgrepVendorSrc)) { + const rgBinName = `rg-${process.platform}-${process.arch}${ + process.platform === 'win32' ? '.exe' : '' + }`; + const rgSrc = join(ripgrepVendorSrc, rgBinName); + if (existsSync(rgSrc)) { + mkdirSync(ripgrepVendorDest, { recursive: true }); + cpSync(rgSrc, join(ripgrepVendorDest, rgBinName), { dereference: true }); + console.log(`Copied ${rgBinName} to bundle/vendor/ripgrep/`); + } else { + console.warn( + `Warning: bundled ripgrep binary not found for ${process.platform}/${process.arch} at ${rgSrc}. ` + + `The SEA will fall back to system grep at runtime.`, + ); + } +} + +// 3. Stage & Sign Native Modules +const includeNativeModules = process.env.BUNDLE_NATIVE_MODULES !== 'false'; +console.log(`Include Native Modules: ${includeNativeModules}`); + +if (includeNativeModules) { + console.log('Staging and signing native modules...'); + // Prepare staging + if (existsSync(stagingDir)) + rmSync(stagingDir, { recursive: true, force: true }); + mkdirSync(stagingDir, { recursive: true }); + + // Copy @lydell/node-pty to staging + const lydellSrc = join(root, 'node_modules/@lydell'); + const lydellStaging = join(stagingDir, 'node_modules/@lydell'); + + if (existsSync(lydellSrc)) { + mkdirSync(dirname(lydellStaging), { recursive: true }); + cpSync(lydellSrc, lydellStaging, { recursive: true }); + } else { + console.warn( + 'Warning: @lydell/node-pty not found in node_modules. Native terminal features may fail.', + ); + } + + // Copy @github/keytar to staging + const githubSrc = join(root, 'node_modules/@github'); + const githubStaging = join(stagingDir, 'node_modules/@github'); + + if (existsSync(githubSrc)) { + mkdirSync(dirname(githubStaging), { recursive: true }); + cpSync(githubSrc, githubStaging, { recursive: true }); + } else { + console.warn( + 'Warning: @github/keytar not found in node_modules. Secure keychain features will use file fallback.', + ); + } + + // Sign Staged .node files + try { + const nodeFiles = globSync('**/*.node', { + cwd: stagingDir, + absolute: true, + }); + for (const file of nodeFiles) { + signFile(file); + } + } catch (e) { + console.warn('Warning: Failed to sign native modules:', e.code); + } +} else { + console.log('Skipping native modules bundling (BUNDLE_NATIVE_MODULES=false)'); +} + +// 4. Generate SEA Configuration and Manifest +console.log('Generating SEA configuration and manifest...'); +const packageJson = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf8'), +); + +// Helper to calc hash +const sha256 = (content) => createHash('sha256').update(content).digest('hex'); + +const assets = { + 'manifest.json': 'bundle/manifest.json', +}; + +const manifest = { + main: 'gemini.mjs', + mainHash: '', + version: packageJson.version, + files: [], +}; + +// Add all javascript chunks from the bundle directory +const jsFiles = globSync('*.js', { cwd: bundleDir }); +for (const jsFile of jsFiles) { + const fsPath = join(bundleDir, jsFile); + const content = readFileSync(fsPath); + const hash = sha256(content); + + // Node SEA requires the main entry point to be explicitly mapped + if (jsFile === 'gemini.js') { + assets['gemini.mjs'] = fsPath; + manifest.mainHash = hash; + } else { + // Other chunks need to be mapped exactly as they are named so dynamic imports find them + assets[jsFile] = fsPath; + manifest.files.push({ key: jsFile, path: jsFile, hash: hash }); + } +} + +// Helper to recursively find files from STAGING +function addAssetsFromDir(baseDir, runtimePrefix) { + const fullDir = join(stagingDir, baseDir); + if (!existsSync(fullDir)) return; + + const items = globSync('**/*', { cwd: fullDir, nodir: true }); + for (const item of items) { + const relativePath = join(runtimePrefix, item); + const assetKey = `files:${relativePath}`; + const fsPath = join(fullDir, item); + + // Calc hash + const content = readFileSync(fsPath); + const hash = sha256(content); + + assets[assetKey] = fsPath; + manifest.files.push({ key: assetKey, path: relativePath, hash: hash }); + } +} + +// Add sb files +const sbFiles = globSync('sandbox-macos-*.sb', { cwd: bundleDir }); +for (const sbFile of sbFiles) { + const fsPath = join(bundleDir, sbFile); + const content = readFileSync(fsPath); + const hash = sha256(content); + assets[sbFile] = fsPath; + manifest.files.push({ key: sbFile, path: sbFile, hash: hash }); +} + +// Add policy files +const policyDir = join(bundleDir, 'policies'); +if (existsSync(policyDir)) { + const policyFiles = globSync('*.toml', { cwd: policyDir }); + for (const policyFile of policyFiles) { + const fsPath = join(policyDir, policyFile); + const relativePath = join('policies', policyFile); + const content = readFileSync(fsPath); + const hash = sha256(content); + // Use a unique key to avoid collision if filenames overlap (though unlikely here) + // But sea-launch writes to 'path', so key is just for lookup. + const assetKey = `policies:${policyFile}`; + assets[assetKey] = fsPath; + manifest.files.push({ key: assetKey, path: relativePath, hash: hash }); + } +} + +// Add ripgrep binary (copied in step 2b). Must be registered here so that +// sea-launch.cjs extracts it to runtimeDir/vendor/ripgrep/ on startup; the +// runtime resolver in packages/core/src/tools/ripGrep.ts uses __dirname- +// relative paths to find it. +if (existsSync(ripgrepVendorDest)) { + const rgFiles = globSync('*', { cwd: ripgrepVendorDest, nodir: true }); + for (const rgFile of rgFiles) { + const fsPath = join(ripgrepVendorDest, rgFile); + const relativePath = join('vendor', 'ripgrep', rgFile); + const content = readFileSync(fsPath); + const hash = sha256(content); + const assetKey = `vendor:${rgFile}`; + assets[assetKey] = fsPath; + manifest.files.push({ key: assetKey, path: relativePath, hash: hash }); + } +} + +// Add assets from Staging +if (includeNativeModules) { + addAssetsFromDir('node_modules/@lydell', 'node_modules/@lydell'); + addAssetsFromDir('node_modules/@github', 'node_modules/@github'); +} + +writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + +const seaConfig = { + main: 'sea/sea-launch.cjs', + output: 'dist/sea-prep.blob', + disableExperimentalSEAWarning: true, + assets: assets, +}; + +writeFileSync(seaConfigPath, JSON.stringify(seaConfig, null, 2)); +console.log(`Configured ${Object.keys(assets).length} embedded assets.`); + +// 5. Generate SEA Blob +console.log('Generating SEA blob...'); +try { + runCommand('node', ['--experimental-sea-config', 'sea-config.json']); +} catch (e) { + console.error('Failed to generate SEA blob:', e.message); + // Cleanup + if (existsSync(seaConfigPath)) rmSync(seaConfigPath); + if (existsSync(manifestPath)) rmSync(manifestPath); + if (existsSync(stagingDir)) + rmSync(stagingDir, { recursive: true, force: true }); + process.exit(1); +} + +// Check blob existence +const blobPath = join(distDir, 'sea-prep.blob'); +if (!existsSync(blobPath)) { + console.error('Error: sea-prep.blob not found in dist/'); + process.exit(1); +} + +// 6. Identify Target & Prepare Binary +const platform = process.platform; +const arch = process.arch; +const targetName = `${platform}-${arch}`; +console.log(`Targeting: ${targetName}`); + +const targetDir = join(distDir, targetName); +mkdirSync(targetDir, { recursive: true }); + +const nodeBinary = process.execPath; +const binaryName = platform === 'win32' ? 'gemini.exe' : 'gemini'; +const targetBinaryPath = join(targetDir, binaryName); + +console.log(`Copying node binary from ${nodeBinary} to ${targetBinaryPath}...`); +copyFileSync(nodeBinary, targetBinaryPath); + +if (platform === 'darwin') { + console.log(`Thinning universal binary for ${arch}...`); + try { + // Attempt to thin the binary. Will fail safely if it's not a fat binary. + runCommand('lipo', [ + targetBinaryPath, + '-thin', + arch, + '-output', + targetBinaryPath, + ]); + } catch (e) { + console.log(`Skipping lipo thinning: ${e.message}`); + } +} + +// Remove existing signature using helper +removeSignature(targetBinaryPath); + +// Copy standard bundle assets (policies, .sb files) +console.log('Copying additional resources...'); +if (existsSync(bundleDir)) { + cpSync(bundleDir, targetDir, { recursive: true }); +} + +// Clean up source JS files from output (we only want embedded) +const filesToRemove = [ + 'gemini.mjs', + 'gemini.mjs.map', + 'gemini-sea.cjs', + 'sea-launch.cjs', + 'manifest.json', + 'native_modules', + 'policies', +]; + +filesToRemove.forEach((f) => { + const p = join(targetDir, f); + if (existsSync(p)) rmSync(p, { recursive: true, force: true }); +}); + +// Remove all chunk and entry .js/.js.map files +const jsFilesToRemove = globSync('*.{js,js.map}', { cwd: targetDir }); +for (const f of jsFilesToRemove) { + rmSync(join(targetDir, f)); +} + +// Remove .sb files from targetDir +const sbFilesToRemove = globSync('sandbox-macos-*.sb', { cwd: targetDir }); +for (const f of sbFilesToRemove) { + rmSync(join(targetDir, f)); +} + +// 7. Inject Blob +console.log('Injecting SEA blob...'); +const sentinelFuse = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; + +try { + chmodSync(targetBinaryPath, 0o755); + const args = [ + 'postject', + targetBinaryPath, + 'NODE_SEA_BLOB', + blobPath, + '--sentinel-fuse', + sentinelFuse, + ]; + + if (platform === 'darwin') { + args.push('--macho-segment-name', 'NODE_SEA'); + } + + runCommand('npx', ['--yes', ...args]); + console.log('Injection successful.'); +} catch (e) { + console.error('Postject failed:', e.message); + process.exit(1); +} + +// 8. Final Signing +console.log('Signing final executable...'); +try { + signFile(targetBinaryPath); +} catch (e) { + console.warn('Warning: Final signing failed:', e.code); + console.warn('Continuing without signing...'); +} + +// 9. Cleanup +console.log('Cleaning up artifacts...'); +rmSync(blobPath); +if (existsSync(seaConfigPath)) rmSync(seaConfigPath); +if (existsSync(manifestPath)) rmSync(manifestPath); +if (existsSync(stagingDir)) + rmSync(stagingDir, { recursive: true, force: true }); + +console.log(`Binary built successfully in ${targetDir}`); diff --git a/scripts/build_sandbox.js b/scripts/build_sandbox.js new file mode 100644 index 0000000000000000000000000000000000000000..84ac96bc99fe46e50af3dac1377d8c3e17adcf86 --- /dev/null +++ b/scripts/build_sandbox.js @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { execSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import os from 'node:os'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import cliPkgJson from '../packages/cli/package.json' with { type: 'json' }; + +const argv = yargs(hideBin(process.argv)) + .option('s', { + alias: 'skip-npm-install-build', + type: 'boolean', + default: false, + description: 'skip npm install + npm run build', + }) + .option('f', { + alias: 'dockerfile', + type: 'string', + default: 'Dockerfile', + description: 'use for custom image', + }) + .option('i', { + alias: 'image', + type: 'string', + default: cliPkgJson.config.sandboxImageUri, + description: 'use name for custom image', + }) + .option('output-file', { + type: 'string', + description: + 'Path to write the final image URI. Used for CI/CD pipeline integration.', + }).argv; + +let sandboxCommand; +try { + sandboxCommand = execSync('node scripts/sandbox_command.js') + .toString() + .trim(); +} catch (e) { + console.warn('ERROR: could not detect sandbox container command'); + console.error(e); + process.exit(process.env.CI ? 1 : 0); +} + +if (sandboxCommand === 'sandbox-exec') { + console.warn( + 'WARNING: container-based sandboxing is disabled (see README.md#sandboxing)', + ); + process.exit(0); +} + +console.log(`using ${sandboxCommand} for sandboxing`); + +const image = argv.i; +const dockerFile = argv.f; + +if (!image.length) { + console.warn( + 'No default image tag specified in gemini-cli/packages/cli/package.json', + ); +} + +if (!argv.s) { + execSync('npm install', { stdio: 'inherit' }); + execSync('npm run build --workspaces', { stdio: 'inherit' }); +} + +console.log('packing @google/gemini-cli ...'); +const cliPackageDir = join('packages', 'cli'); +rmSync(join(cliPackageDir, 'dist', 'google-gemini-cli-*.tgz'), { force: true }); +execSync( + `npm pack -w @google/gemini-cli --pack-destination ./packages/cli/dist`, + { + stdio: 'ignore', + }, +); + +console.log('packing @google/gemini-cli-core ...'); +const corePackageDir = join('packages', 'core'); +rmSync(join(corePackageDir, 'dist', 'google-gemini-cli-core-*.tgz'), { + force: true, +}); +execSync( + `npm pack -w @google/gemini-cli-core --pack-destination ./packages/core/dist`, + { stdio: 'ignore' }, +); + +const packageVersion = JSON.parse( + readFileSync(join(process.cwd(), 'package.json'), 'utf-8'), +).version; + +chmodSync( + join(cliPackageDir, 'dist', `google-gemini-cli-${packageVersion}.tgz`), + 0o755, +); +chmodSync( + join(corePackageDir, 'dist', `google-gemini-cli-core-${packageVersion}.tgz`), + 0o755, +); + +const buildStdout = process.env.VERBOSE ? 'inherit' : 'ignore'; + +// Determine the appropriate shell based on OS +const isWindows = os.platform() === 'win32'; +const shellToUse = isWindows ? 'powershell.exe' : '/bin/bash'; + +function buildImage(imageName, dockerfile) { + console.log(`building ${imageName} ... (can be slow first time)`); + + let buildCommandArgs = ''; + let tempAuthFile = ''; + + if (sandboxCommand === 'podman') { + if (isWindows) { + // PowerShell doesn't support <() process substitution. + // Create a temporary auth file that we will clean up after. + tempAuthFile = join(os.tmpdir(), `gemini-auth-${Date.now()}.json`); + writeFileSync(tempAuthFile, '{}'); + buildCommandArgs = `--authfile="${tempAuthFile}"`; + } else { + // Use bash-specific syntax for Linux/macOS + buildCommandArgs = `--authfile=<(echo '{}')`; + } + } + + const npmPackageVersion = JSON.parse( + readFileSync(join(process.cwd(), 'package.json'), 'utf-8'), + ).version; + + const imageTag = + process.env.GEMINI_SANDBOX_IMAGE_TAG || imageName.split(':')[1]; + const finalImageName = `${imageName.split(':')[0]}:${imageTag}`; + + try { + execSync( + `${sandboxCommand} build ${buildCommandArgs} ${ + process.env.BUILD_SANDBOX_FLAGS || '' + } --build-arg CLI_VERSION_ARG=${npmPackageVersion} -f "${dockerfile}" -t "${finalImageName}" .`, + { stdio: buildStdout, shell: shellToUse }, + ); + console.log(`built ${finalImageName}`); + + // If an output file path was provided via command-line, write the final image URI to it. + if (argv.outputFile) { + console.log( + `Writing final image URI for CI artifact to: ${argv.outputFile}`, + ); + // The publish step only supports one image. If we build multiple, only the last one + // will be published. Throw an error to make this failure explicit if the file already exists. + if (existsSync(argv.outputFile)) { + throw new Error( + `CI artifact file ${argv.outputFile} already exists. Refusing to overwrite.`, + ); + } + writeFileSync(argv.outputFile, finalImageName); + } + } finally { + // If we created a temp file, delete it now. + if (tempAuthFile) { + rmSync(tempAuthFile, { force: true }); + } + } +} + +buildImage(image, dockerFile); + +execSync(`${sandboxCommand} image prune -f`, { stdio: 'ignore' }); diff --git a/scripts/build_vscode_companion.js b/scripts/build_vscode_companion.js new file mode 100644 index 0000000000000000000000000000000000000000..d2b981b6c222b9949d0d263efd67391de83f12da --- /dev/null +++ b/scripts/build_vscode_companion.js @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { execSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +execSync('npm --workspace=gemini-cli-vscode-ide-companion run package', { + stdio: 'inherit', + cwd: root, +}); diff --git a/scripts/check-lockfile.js b/scripts/check-lockfile.js new file mode 100644 index 0000000000000000000000000000000000000000..a66fd8bfab88696d5754c654b8e18ea6032b2d06 --- /dev/null +++ b/scripts/check-lockfile.js @@ -0,0 +1,117 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const lockfilePath = join(root, 'package-lock.json'); + +function readJsonFile(filePath) { + try { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(fileContent); + } catch (error) { + console.error(`Error reading or parsing ${filePath}:`, error); + return null; + } +} + +console.log('Checking lockfile...'); + +const lockfile = readJsonFile(lockfilePath); +if (lockfile === null) { + process.exit(1); +} +const packages = lockfile.packages || {}; +const invalidPackages = []; + +for (const [location, details] of Object.entries(packages)) { + // 1. Skip the root package itself. + if (location === '') { + continue; + } + + // 2. Skip local workspace packages. + // They are identifiable in two ways: + // a) As a symlink within node_modules. + // b) As the source package definition, whose path is not in node_modules. + if (details.link === true || !location.includes('node_modules')) { + continue; + } + + // 3. Any remaining package should be a third-party dependency. + // 1) Registry package with both "resolved" and "integrity" fields is valid. + if (details.resolved && details.integrity) { + continue; + } + // 2) Git and file dependencies only need a "resolved" field. + const isGitOrFileDep = + details.resolved?.startsWith('git') || + details.resolved?.startsWith('file:'); + if (isGitOrFileDep) { + continue; + } + + // Mark the left dependency as invalid. + invalidPackages.push(location); +} + +if (invalidPackages.length > 0) { + console.error( + '\nError: The following dependencies in package-lock.json are missing the "resolved" or "integrity" field:', + ); + invalidPackages.forEach((pkg) => console.error(`- ${pkg}`)); + process.exitCode = 1; +} else { + console.log('Lockfile check passed.'); +} + +// Check that gaxios v7+ with stream corruption bug is NOT resolved in any workspace node_modules. +// gaxios v7.x (versions < 7.1.6) has a bug where Array.toString() joins stream chunks with +// commas, corrupting error response JSON at TCP chunk boundaries. +// See: https://github.com/google-gemini/gemini-cli/pull/21884 +function isCorruptedGaxios(version) { + if (!version) return false; + const match = version.match(/^7\.(\d+)\.(\d+)/); + if (match) { + const minor = parseInt(match[1], 10); + const patch = parseInt(match[2], 10); + if (minor < 1 || (minor === 1 && patch < 6)) { + return true; + } + } + return false; +} + +const gaxiosViolations = []; +for (const [location, details] of Object.entries(packages)) { + if ( + location.match(/(^|\/)node_modules\/gaxios$/) && + !location.includes('@google/genai/node_modules') && + isCorruptedGaxios(details.version) + ) { + gaxiosViolations.push(`${location} (v${details.version})`); + } +} + +if (gaxiosViolations.length > 0) { + console.error( + '\nError: gaxios versions with stream corruption bug (v7.x < 7.1.6) detected in workspace node_modules.', + ); + console.error('See: https://github.com/google-gemini/gemini-cli/pull/21884'); + gaxiosViolations.forEach((v) => console.error(`- ${v}`)); + console.error( + '\nPlease ensure gaxios resolves to a version containing the fix (>= 7.1.6).', + ); + process.exitCode = 1; +} + +if (!process.exitCode) { + process.exitCode = 0; +} diff --git a/scripts/clean.js b/scripts/clean.js new file mode 100644 index 0000000000000000000000000000000000000000..dbb3849b150d9a68c68cfa60835c4cc61adf8ab3 --- /dev/null +++ b/scripts/clean.js @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { rmSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +// remove npm install/build artifacts +rmSync(join(root, 'node_modules'), { recursive: true, force: true }); +rmSync(join(root, 'bundle'), { recursive: true, force: true }); +rmSync(join(root, 'packages/cli/src/generated/'), { + recursive: true, + force: true, +}); +const RMRF_OPTIONS = { recursive: true, force: true }; +rmSync(join(root, 'bundle'), RMRF_OPTIONS); +// Dynamically clean dist directories in all workspaces +const rootPackageJson = JSON.parse( + readFileSync(join(root, 'package.json'), 'utf-8'), +); +for (const workspace of rootPackageJson.workspaces) { + // Note: this is a simple glob implementation that only supports "packages/*". + const workspaceDir = join(root, dirname(workspace)); + const packageDirs = readdirSync(workspaceDir); + + for (const pkg of packageDirs) { + const pkgDir = join(workspaceDir, pkg); + try { + if (statSync(pkgDir).isDirectory()) { + rmSync(join(pkgDir, 'dist'), RMRF_OPTIONS); + } + } catch (e) { + if (e.code !== 'ENOENT') { + throw e; + } + } + } +} + +// Clean up vscode-ide-companion package +rmSync(join(root, 'packages/vscode-ide-companion/node_modules'), { + recursive: true, + force: true, +}); + +const vscodeCompanionDir = join(root, 'packages/vscode-ide-companion'); +try { + const files = readdirSync(vscodeCompanionDir); + for (const file of files) { + if (file.endsWith('.vsix')) { + rmSync(join(vscodeCompanionDir, file), RMRF_OPTIONS); + } + } +} catch (e) { + if (e.code !== 'ENOENT') { + throw e; + } +} diff --git a/scripts/cleanup-branches.ts b/scripts/cleanup-branches.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfa4da6e35a1041b20569a939a4f52d4ae1bbedb --- /dev/null +++ b/scripts/cleanup-branches.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import * as readline from 'node:readline/promises'; +import * as process from 'node:process'; + +function runCmd(cmd: string): string { + return execSync(cmd, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'], + }).trim(); +} + +async function main() { + try { + runCmd('gh --version'); + } catch { + console.error( + 'Error: "gh" CLI is required but not installed or not working.', + ); + process.exit(1); + } + + try { + runCmd('git --version'); + } catch { + console.error('Error: "git" is required.'); + process.exit(1); + } + + console.log('Fetching remote branches from origin...'); + let allBranchesOutput = ''; + try { + // Also fetch to ensure we have the latest commit dates + console.log( + 'Running git fetch to ensure we have up-to-date commit dates and prune stale branches...', + ); + runCmd('git fetch origin --prune'); + + // Get all branches with their commit dates + allBranchesOutput = runCmd( + "git for-each-ref --format='%(refname:lstrip=3) %(committerdate:unix)' refs/remotes/origin", + ); + } catch { + console.error('Failed to fetch branches from origin.'); + process.exit(1); + } + + const THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60; + const now = Math.floor(Date.now() / 1000); + + const remoteBranches: { name: string; lastCommitDate: number }[] = + allBranchesOutput + .split(/\r?\n/) + .map((line) => { + const parts = line.split(' '); + if (parts.length < 2) return null; + const date = parseInt(parts.pop() || '0', 10); + const name = parts.join(' '); + return { name, lastCommitDate: date }; + }) + .filter((b): b is { name: string; lastCommitDate: number } => b !== null); + + console.log(`Found ${remoteBranches.length} branches on origin.`); + + console.log('Fetching open PRs...'); + let openPrsJson = '[]'; + try { + openPrsJson = runCmd( + 'gh pr list --state open --limit 5000 --json headRefName', + ); + } catch { + console.error('Failed to fetch open PRs.'); + process.exit(1); + } + + const openPrs = JSON.parse(openPrsJson); + const openPrBranches = new Set( + openPrs.map((pr: { headRefName: string }) => pr.headRefName), + ); + + const protectedPattern = + /^(main|master|next|release[-/].*|hotfix[-/].*|v\d+.*|HEAD|gh-readonly-queue.*)$/; + + const branchesToDelete = remoteBranches.filter((branch) => { + if (protectedPattern.test(branch.name)) { + return false; + } + if (openPrBranches.has(branch.name)) { + return false; + } + + const ageInSeconds = now - branch.lastCommitDate; + if (ageInSeconds < THIRTY_DAYS_IN_SECONDS) { + return false; // Skip branches pushed to recently + } + + return true; + }); + + if (branchesToDelete.length === 0) { + console.log('No remote branches to delete.'); + return; + } + + console.log( + '\nThe following remote branches are NOT release branches, have NO active PR, and are OLDER than 30 days:', + ); + console.log( + '---------------------------------------------------------------------', + ); + branchesToDelete.forEach((b) => console.log(` - ${b.name}`)); + console.log( + '---------------------------------------------------------------------', + ); + console.log(`Total to delete: ${branchesToDelete.length}`); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const answer = await rl.question( + `\nDo you want to delete these ${branchesToDelete.length} remote branches from origin? (y/N) `, + ); + rl.close(); + + if (answer.toLowerCase() === 'y') { + console.log('Deleting remote branches...'); + // Delete in batches to avoid hitting command line length limits + const batchSize = 50; + for (let i = 0; i < branchesToDelete.length; i += batchSize) { + const batch = branchesToDelete.slice(i, i + batchSize).map((b) => b.name); + const branchList = batch.join(' '); + console.log(`Deleting remote batch ${Math.floor(i / batchSize) + 1}...`); + try { + execSync(`git push origin --delete ${branchList}`, { + stdio: 'inherit', + }); + } catch { + console.warn('Batch failed, trying to delete branches individually...'); + for (const branch of batch) { + try { + execSync(`git push origin --delete ${branch}`, { + stdio: 'pipe', + }); + } catch (err: unknown) { + const error = err as { stderr?: Buffer; message?: string }; + const stderr = error.stderr?.toString() || ''; + if (!stderr.includes('remote ref does not exist')) { + console.error( + `Failed to delete branch "${branch}":`, + stderr.trim() || error.message, + ); + } + } + } + } + } + + console.log('Cleaning up local tracking branches...'); + try { + execSync('git remote prune origin', { stdio: 'inherit' }); + } catch { + console.error('Failed to prune local tracking branches.'); + } + console.log('Cleanup complete.'); + } else { + console.log('Operation cancelled.'); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/close_duplicate_issues.js b/scripts/close_duplicate_issues.js new file mode 100644 index 0000000000000000000000000000000000000000..087ec59b4c5ba8d014c4f7ad4f744794e402f443 --- /dev/null +++ b/scripts/close_duplicate_issues.js @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { Octokit } from '@octokit/rest'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import prompts from 'prompts'; + +if (!process.env.GITHUB_TOKEN) { + console.error('Error: GITHUB_TOKEN environment variable is required.'); + process.exit(1); +} + +const argv = yargs(hideBin(process.argv)) + .option('query', { + alias: 'q', + type: 'string', + description: + 'Search query to find duplicate issues (e.g. "function response parts")', + demandOption: true, + }) + .option('canonical', { + alias: 'c', + type: 'number', + description: 'The canonical issue number to duplicate others to', + demandOption: true, + }) + .option('pr', { + type: 'string', + description: + 'Optional Pull Request URL or ID to mention in the closing comment', + }) + .option('owner', { + type: 'string', + default: 'google-gemini', + description: 'Repository owner', + }) + .option('repo', { + type: 'string', + default: 'gemini-cli', + description: 'Repository name', + }) + .option('dry-run', { + alias: 'd', + type: 'boolean', + default: false, + description: 'Run without making actual changes (read-only mode)', + }) + .option('auto', { + type: 'boolean', + default: false, + description: + 'Automatically close all duplicates without prompting (batch mode)', + }) + .help() + .parse(); + +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, +}); + +const { query, canonical, pr, owner, repo, dryRun, auto } = argv; + +// Construct the full search query ensuring it targets the specific repo and open issues +const fullSearchQuery = `repo:${owner}/${repo} is:issue is:open ${query}`; + +async function run() { + console.log(`Searching for issues matching: ${fullSearchQuery}`); + if (dryRun) { + console.log('--- DRY RUN MODE: No changes will be made ---'); + } + + try { + const issues = await octokit.paginate( + octokit.rest.search.issuesAndPullRequests, + { + q: fullSearchQuery, + }, + ); + + console.log(`Found ${issues.length} issues.`); + + for (const issue of issues) { + if (issue.number === canonical) { + console.log(`Skipping canonical issue #${issue.number}`); + continue; + } + + console.log( + `Processing issue #${issue.number}: ${issue.title} (by @${issue.user?.login})`, + ); + + if (!auto && !dryRun) { + const response = await prompts({ + type: 'confirm', + name: 'value', + message: `Close issue #${issue.number} "${issue.title}" created by @${issue.user?.login}?`, + initial: true, + }); + + if (!response.value) { + console.log(`Skipping issue #${issue.number}`); + continue; + } + } + + let commentBody = `Closing this issue as a duplicate of #${canonical}.`; + if (pr) { + commentBody += ` Please note that this issue should be resolved by PR ${pr}.`; + } + + try { + if (!dryRun) { + // Add comment + await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: commentBody, + }); + console.log(` Added comment.`); + + // Close issue + await octokit.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'duplicate', + }); + console.log(` Closed issue.`); + } else { + console.log(` [DRY RUN] Would add comment: "${commentBody}"`); + console.log(` [DRY RUN] Would close issue #${issue.number}`); + } + } catch (error) { + console.error( + ` Failed to process issue #${issue.number}:`, + error.message, + ); + } + } + } catch (error) { + console.error('Error searching for issues:', error.message); + process.exit(1); + } +} + +run().catch(console.error); diff --git a/scripts/compare_evals.js b/scripts/compare_evals.js new file mode 100644 index 0000000000000000000000000000000000000000..a5ea15361f3ceed4582cbcc8483417003d416a7f --- /dev/null +++ b/scripts/compare_evals.js @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Compares PR evaluation results against historical nightly baselines. + * + * This script generates a Markdown report for use in PR comments. It aligns with + * the 6-day lookback logic to show accurate historical pass rates and filters out + * pre-existing or noisy failures to ensure only actionable regressions are reported. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fetchNightlyHistory } from './eval_utils.js'; + +/** + * Main execution logic. + */ +function main() { + const prReportPath = 'evals/logs/pr_final_report.json'; + const targetModel = process.argv[2]; + + if (!targetModel) { + console.error('โŒ Error: No target model specified.'); + process.exit(1); + } + + if (!fs.existsSync(prReportPath)) { + console.error('No PR report found.'); + return; + } + + const prReport = JSON.parse(fs.readFileSync(prReportPath, 'utf-8')); + const history = fetchNightlyHistory(6); // Use same 6-day lookback + const latestNightly = aggregateHistoricalStats(history, targetModel); + + const regressions = []; + const passes = []; + + for (const [testName, pr] of Object.entries(prReport.results)) { + const prRate = pr.passed / pr.total; + if (pr.status === 'regression' || (prRate <= 0.34 && !pr.status)) { + // Use relative path from workspace root + const relativeFile = pr.file + ? path.relative(process.cwd(), pr.file) + : 'evals/'; + + regressions.push({ + name: testName, + file: relativeFile, + nightly: latestNightly[testName] + ? (latestNightly[testName].passRate * 100).toFixed(0) + '%' + : 'N/A', + pr: (prRate * 100).toFixed(0) + '%', + }); + } else { + passes.push(testName); + } + } + + if (regressions.length > 0) { + let markdown = '### ๐Ÿšจ Action Required: Eval Regressions Detected\n\n'; + markdown += `**Model:** \`${targetModel}\`\n\n`; + markdown += + 'The following trustworthy evaluations passed on **`main`** and in **recent Nightly runs**, but failed in this PR. These regressions must be addressed before merging.\n\n'; + + markdown += '| Test Name | Nightly | PR Result | Status |\n'; + markdown += '| :--- | :---: | :---: | :--- |\n'; + for (const r of regressions) { + markdown += `| ${r.name} | ${r.nightly} | ${r.pr} | โŒ **Regression** |\n`; + } + markdown += `\n*The check passed or was cleared for ${passes.length} other trustworthy evaluations.*\n\n`; + + markdown += '
\n'; + markdown += + '๐Ÿ› ๏ธ Troubleshooting & Fix Instructions\n\n'; + + for (let i = 0; i < regressions.length; i++) { + const r = regressions[i]; + if (regressions.length > 1) { + markdown += `### Failure ${i + 1}: ${r.name}\n\n`; + } + + markdown += '#### 1. Ask Gemini CLI to fix it (Recommended)\n'; + markdown += 'Copy and paste this prompt to the agent:\n'; + markdown += '```text\n'; + markdown += `The eval "${r.name}" in ${r.file} is failing. Investigate and fix it using the behavioral-evals skill.\n`; + markdown += '```\n\n'; + + markdown += '#### 2. Reproduce Locally\n'; + markdown += 'Run the following command to see the failure trajectory:\n'; + markdown += '```bash\n'; + const pattern = r.name.replace(/'/g, '.'); + markdown += `GEMINI_MODEL=${targetModel} npm run test:all_evals -- ${r.file} --testNamePattern="${pattern}"\n`; + + markdown += '```\n\n'; + + if (i < regressions.length - 1) { + markdown += '---\n\n'; + } + } + + markdown += '#### 3. Manual Fix\n'; + markdown += + 'See the [Fixing Guide](https://github.com/google-gemini/gemini-cli/blob/main/evals/README.md#fixing-evaluations) for detailed troubleshooting steps.\n'; + markdown += '
\n'; + + process.stdout.write(markdown); + } else if (passes.length > 0) { + // Success State + process.stdout.write( + `โœ… **${passes.length}** tests passed successfully on **${targetModel}**.\n`, + ); + } +} + +/** + * Aggregates stats from history for a specific model. + */ +function aggregateHistoricalStats(history, model) { + const stats = {}; + for (const item of history) { + const modelStats = item.stats[model]; + if (!modelStats) continue; + + for (const [testName, stat] of Object.entries(modelStats)) { + if (!stats[testName]) stats[testName] = { passed: 0, total: 0 }; + stats[testName].passed += stat.passed; + stats[testName].total += stat.total; + } + } + + for (const name in stats) { + stats[name].passRate = stats[name].passed / stats[name].total; + } + return stats; +} + +main(); diff --git a/scripts/copy_bundle_assets.js b/scripts/copy_bundle_assets.js new file mode 100644 index 0000000000000000000000000000000000000000..94de07ade143de557b360151f3610f06fde6653f --- /dev/null +++ b/scripts/copy_bundle_assets.js @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { copyFileSync, existsSync, mkdirSync, cpSync } from 'node:fs'; +import { dirname, join, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { glob } from 'glob'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const bundleDir = join(root, 'bundle'); + +// Create the bundle directory if it doesn't exist +if (!existsSync(bundleDir)) { + mkdirSync(bundleDir); +} + +// 1. Copy Sandbox definitions (.sb) +const sbFiles = glob.sync('packages/**/*.sb', { cwd: root }); +for (const file of sbFiles) { + copyFileSync(join(root, file), join(bundleDir, basename(file))); +} + +// 2. Copy Policy definitions (.toml) +const policyDir = join(bundleDir, 'policies'); +if (!existsSync(policyDir)) { + mkdirSync(policyDir); +} + +// Locate policy files specifically in the core package +const policyFiles = glob.sync('packages/core/src/policy/policies/*.toml', { + cwd: root, +}); + +for (const file of policyFiles) { + copyFileSync(join(root, file), join(policyDir, basename(file))); +} + +console.log(`Copied ${policyFiles.length} policy files to bundle/policies/`); + +// Also copy policies to a2a-server dist directory for bundled execution +const a2aPolicyDir = join(root, 'packages/a2a-server/dist/policies'); +if (!existsSync(a2aPolicyDir)) { + mkdirSync(a2aPolicyDir, { recursive: true }); +} +for (const file of policyFiles) { + copyFileSync(join(root, file), join(a2aPolicyDir, basename(file))); +} +console.log( + `Copied ${policyFiles.length} policy files to packages/a2a-server/dist/policies/`, +); + +// 3. Copy Documentation (docs/) +const docsSrc = join(root, 'docs'); +const docsDest = join(bundleDir, 'docs'); +if (existsSync(docsSrc)) { + cpSync(docsSrc, docsDest, { recursive: true, dereference: true }); + console.log('Copied docs to bundle/docs/'); +} + +// 4. Copy Built-in Skills (packages/core/src/skills/builtin) +const builtinSkillsSrc = join(root, 'packages/core/src/skills/builtin'); +const builtinSkillsDest = join(bundleDir, 'builtin'); +if (existsSync(builtinSkillsSrc)) { + cpSync(builtinSkillsSrc, builtinSkillsDest, { + recursive: true, + dereference: true, + }); + console.log('Copied built-in skills to bundle/builtin/'); +} + +// 5. Copy bundled chrome-devtools-mcp +const bundleMcpSrc = join(root, 'packages/core/dist/bundled'); +const bundleMcpDest = join(bundleDir, 'bundled'); +if (!existsSync(bundleMcpSrc)) { + console.error( + `Error: chrome-devtools-mcp bundle not found at ${bundleMcpSrc}.\n` + + `Run "npm run bundle:browser-mcp -w @google/gemini-cli-core" first.`, + ); + process.exit(1); +} +cpSync(bundleMcpSrc, bundleMcpDest, { recursive: true, dereference: true }); +console.log('Copied bundled chrome-devtools-mcp to bundle/bundled/'); + +// 6. Copy Extension Examples +const extensionExamplesSrc = join( + root, + 'packages/cli/src/commands/extensions/examples', +); +const extensionExamplesDest = join(bundleDir, 'examples'); +const EXCLUDED_EXAMPLE_DIRS = ['node_modules', 'dist']; + +if (existsSync(extensionExamplesSrc)) { + cpSync(extensionExamplesSrc, extensionExamplesDest, { + recursive: true, + dereference: true, + filter: (src) => !EXCLUDED_EXAMPLE_DIRS.some((dir) => src.includes(dir)), + }); + console.log('Copied extension examples to bundle/examples/'); +} + +console.log('Assets copied to bundle/'); diff --git a/scripts/copy_files.js b/scripts/copy_files.js new file mode 100644 index 0000000000000000000000000000000000000000..d02070362fe52f85e465ac318249a0dfff0345d0 --- /dev/null +++ b/scripts/copy_files.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import fs from 'node:fs'; +import path from 'node:path'; + +const sourceDir = path.join('src'); +const targetDir = path.join('dist', 'src'); + +const extensionsToCopy = ['.md', '.json', '.sb', '.toml', '.cs', '.exe']; + +function copyFilesRecursive(source, target) { + if (!fs.existsSync(target)) { + fs.mkdirSync(target, { recursive: true }); + } + + const items = fs.readdirSync(source, { withFileTypes: true }); + + for (const item of items) { + const sourcePath = path.join(source, item.name); + const targetPath = path.join(target, item.name); + + if (item.isDirectory()) { + copyFilesRecursive(sourcePath, targetPath); + } else if (extensionsToCopy.includes(path.extname(item.name))) { + fs.copyFileSync(sourcePath, targetPath); + } + } +} + +if (!fs.existsSync(sourceDir)) { + console.error(`Source directory ${sourceDir} not found.`); + process.exit(1); +} + +copyFilesRecursive(sourceDir, targetDir); + +// Copy example extensions into the bundle. +const packageName = path.basename(process.cwd()); +if (packageName === 'cli') { + const examplesSource = path.join( + sourceDir, + 'commands', + 'extensions', + 'examples', + ); + const examplesTarget = path.join( + targetDir, + 'commands', + 'extensions', + 'examples', + ); + if (fs.existsSync(examplesSource)) { + fs.cpSync(examplesSource, examplesTarget, { recursive: true }); + } +} + +// Copy built-in skills for the core package. +if (packageName === 'core') { + const builtinSkillsSource = path.join(sourceDir, 'skills', 'builtin'); + const builtinSkillsTarget = path.join(targetDir, 'skills', 'builtin'); + if (fs.existsSync(builtinSkillsSource)) { + fs.cpSync(builtinSkillsSource, builtinSkillsTarget, { recursive: true }); + } +} + +console.log('Successfully copied files.'); diff --git a/scripts/create_alias.sh b/scripts/create_alias.sh new file mode 100644 index 0000000000000000000000000000000000000000..ecb01bb329e575e530dccfa57c6a30256cbfeb77 --- /dev/null +++ b/scripts/create_alias.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script creates an alias for the Gemini CLI + +# Determine the project directory +PROJECT_DIR=$(cd "$(dirname "$0")/.." && pwd) +ALIAS_COMMAND="alias gemini='node "${PROJECT_DIR}/scripts/start.js"'" + +# Detect shell and set config file path +if [[ "${SHELL}" == *"/bash" ]]; then + CONFIG_FILE="${HOME}/.bashrc" +elif [[ "${SHELL}" == *"/zsh" ]]; then + CONFIG_FILE="${HOME}/.zshrc" +else + echo "Unsupported shell. Only bash and zsh are supported." + exit 1 +fi + +echo "This script will add the following alias to your shell configuration file (${CONFIG_FILE}):" +echo " ${ALIAS_COMMAND}" +echo "" + +# Check if the alias already exists +if grep -q "alias gemini=" "${CONFIG_FILE}"; then + echo "A 'gemini' alias already exists in ${CONFIG_FILE}. No changes were made." + exit 0 +fi + +read -p "Do you want to proceed? (y/n) " -n 1 -r +echo "" +if [[ "${REPLY}" =~ ^[Yy]$ ]]; then + echo "${ALIAS_COMMAND}" >> "${CONFIG_FILE}" + echo "" + echo "Alias added to ${CONFIG_FILE}." + echo "Please run 'source ${CONFIG_FILE}' or open a new terminal to use the 'gemini' command." +else + echo "Aborted. No changes were made." +fi diff --git a/scripts/deflake.js b/scripts/deflake.js new file mode 100644 index 0000000000000000000000000000000000000000..798af10d779694078882c2716c89b7545c6e044c --- /dev/null +++ b/scripts/deflake.js @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +// Script to deflake tests +// Ex. npm run deflake -- --command="npm run test:e2e -- --test-name-pattern 'extension'" --runs=3 + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const dockerIgnorePath = path.join(projectRoot, '.dockerignore'); + +const DOCKERIGNORE_CONTENT = `.integration-tests`.trim(); + +/** + * Runs a command and streams its output to the console. + * @param {string} command The command string to execute (e.g., 'npm run test:e2e -- --watch'). + * @returns {Promise} A Promise that resolves with the exit code of the process. + */ +function runCommand(cmd, args = []) { + if (!cmd) { + return Promise.resolve(1); + } + + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + shell: true, + stdio: 'inherit', + env: { ...process.env }, + }); + + child.on('close', (code) => { + resolve(code ?? 1); // code can be null if the process was killed + }); + + child.on('error', (err) => { + // An error occurred in spawning the process (e.g., command not found). + console.error(`Failed to start command: ${err.message}`); + reject(err); + }); + }); +} +// ------------------------------------------------------------------- + +async function main() { + const argv = await yargs(hideBin(process.argv)) + .option('command', { + type: 'string', + demandOption: true, + description: 'The command to run', + }) + .option('runs', { + type: 'number', + default: 5, + description: 'The number of runs to perform', + }).argv; + + const NUM_RUNS = argv.runs; + const COMMAND = argv.command; + const ARGS = argv._; + let failures = 0; + + const backupDockerIgnorePath = dockerIgnorePath + '.bak'; + let originalDockerIgnoreRenamed = false; + + console.log(`--- Starting Deflake Run (${NUM_RUNS} iterations) ---`); + + try { + try { + // Try to rename to back up an existing .dockerignore + await fs.rename(dockerIgnorePath, backupDockerIgnorePath); + originalDockerIgnoreRenamed = true; + } catch (err) { + // If the file doesn't exist, that's fine. Otherwise, rethrow. + if (err.code !== 'ENOENT') throw err; + } + + // Create the temporary .dockerignore for this run. + await fs.writeFile(dockerIgnorePath, DOCKERIGNORE_CONTENT); + + for (let i = 1; i <= NUM_RUNS; i++) { + console.log(`\n[RUN ${i}/${NUM_RUNS}]`); + + try { + const exitCode = await runCommand(COMMAND, ARGS); + + if (exitCode === 0) { + console.log('โœ… Run PASS'); + } else { + console.log(`โŒ Run FAIL (Exit Code: ${exitCode})`); + failures++; + } + } catch (error) { + console.error('โŒ Run FAIL (Execution Error)', error); + failures++; + } + } + } finally { + try { + // Clean up the temporary .dockerignore + await fs.unlink(dockerIgnorePath); + } catch (err) { + console.error('Failed to remove temporary .dockerignore:', err); + } + + if (originalDockerIgnoreRenamed) { + try { + // Restore the original .dockerignore if it was backed up. + await fs.rename(backupDockerIgnorePath, dockerIgnorePath); + } catch (err) { + console.error('Failed to restore original .dockerignore:', err); + } + } + } + + console.log('\n--- FINAL DEFLAKE SUMMARY ---'); + console.log(`Total Runs: ${NUM_RUNS}`); + console.log(`Total Failures: ${failures}`); + + process.exit(failures > 0 ? 1 : 0); +} + +main().catch((error) => { + console.error('Error in deflake:', error); + process.exit(1); +}); diff --git a/scripts/entitlements.plist b/scripts/entitlements.plist new file mode 100644 index 0000000000000000000000000000000000000000..05eb590010e8ec636025794dbd2311c1909192e6 --- /dev/null +++ b/scripts/entitlements.plist @@ -0,0 +1,21 @@ + + + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.cs.allow-dyld-environment-variables + + + diff --git a/scripts/eval-coverage-cli.ts b/scripts/eval-coverage-cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c82f6ed55c4c0f91bda436931720e31ff5f15ca --- /dev/null +++ b/scripts/eval-coverage-cli.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env tsx + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { collectInventory } from './utils/eval-inventory.js'; +import { buildToolRegistry } from './utils/tool-registry.js'; +import { + computeCoverage, + formatCoverageReport, +} from './utils/eval-coverage.js'; + +async function main() { + const rootFlagIndex = process.argv.indexOf('--root'); + const rootFlagValue = + rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined; + + if (rootFlagIndex !== -1 && rootFlagValue === undefined) { + console.error( + 'Error: --root requires a directory path argument but none was provided.', + ); + process.exit(1); + } + if (rootFlagValue && rootFlagValue.startsWith('--')) { + console.error( + `Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`, + ); + process.exit(1); + } + + const repoRoot = rootFlagValue ?? process.cwd(); + const inventory = await collectInventory(repoRoot); + + if (inventory.totalFiles === 0) { + console.error('No eval files found under evals/.'); + process.exit(1); + } + + const registry = buildToolRegistry(); + const result = computeCoverage(inventory, registry); + + console.log(formatCoverageReport(result)); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/eval-inventory-cli.ts b/scripts/eval-inventory-cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..89eee729c1991f95c6d263c7bcf69bf0137c10d6 --- /dev/null +++ b/scripts/eval-inventory-cli.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env tsx + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview CLI entry point for the eval inventory command. + * + * Scans all eval source files, runs the static analyzer on each, + * and prints an inventory report grouped by policy, file, and suite. + * + * Usage: + * npm run eval:inventory + * npm run eval:inventory -- --json + * npm run eval:inventory -- --root /path/to/repo + * npm run eval:inventory -- --root /path/to/repo --json + */ + +import { + collectInventory, + formatInventoryJson, + formatInventoryReport, +} from './utils/eval-inventory.js'; + +async function main() { + const rootFlagIndex = process.argv.indexOf('--root'); + const rootFlagValue = + rootFlagIndex !== -1 ? process.argv[rootFlagIndex + 1] : undefined; + if (rootFlagIndex !== -1 && rootFlagValue === undefined) { + console.error( + 'Error: --root requires a directory path argument but none was provided.', + ); + process.exit(1); + } + if (rootFlagValue && rootFlagValue.startsWith('--')) { + console.error( + `Error: --root value "${rootFlagValue}" looks like a flag. Provide a valid directory path.`, + ); + process.exit(1); + } + const repoRoot = rootFlagValue ?? process.cwd(); + + const jsonMode = process.argv.includes('--json'); + + const result = await collectInventory(repoRoot); + + if (result.totalFiles === 0) { + console.error('No eval files found under evals/.'); + process.exit(1); + } + + console.log( + jsonMode ? formatInventoryJson(result) : formatInventoryReport(result), + ); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/eval-report-cli.ts b/scripts/eval-report-cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..c438aa366e6d2ff95abde1edff1a9e5810663638 --- /dev/null +++ b/scripts/eval-report-cli.ts @@ -0,0 +1,81 @@ +๏ปฟ#!/usr/bin/env tsx + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview CLI entry point to summarize eval report.json files. + * + * Scans a directory for report.json files, groups them by model name, + * and prints pass rate summaries. Integrates with static inventory data + * to display static policies. + * + * Usage: + * npm run eval:report + * npm run eval:report -- [--json] [--root ] + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import { collectInventory } from './utils/eval-inventory.js'; +import { + summarizeReports, + formatReportSummary, + formatReportSummaryJson, +} from './utils/eval-report.js'; + +async function main() { + const args = process.argv.slice(2); + + const jsonFlagIndex = args.indexOf('--json'); + const jsonMode = jsonFlagIndex !== -1; + if (jsonMode) args.splice(jsonFlagIndex, 1); + + const rootFlagIndex = args.indexOf('--root'); + let repoRoot: string | undefined; + if (rootFlagIndex !== -1) { + repoRoot = args[rootFlagIndex + 1]; + if (repoRoot === undefined || repoRoot.startsWith('--')) { + console.error('Error: --root requires a valid directory path.'); + process.exit(1); + } + args.splice(rootFlagIndex, 2); + } + + const resolvedRoot = repoRoot ? path.resolve(repoRoot) : process.cwd(); + + // The first positional argument is the directory of reports + const reportsDirArg = args.find((a) => !a.startsWith('--')); + const reportsDir = reportsDirArg + ? path.resolve(reportsDirArg) + : path.join(resolvedRoot, 'evals', 'logs'); + + if (!fs.existsSync(reportsDir)) { + console.error(`Error: Reports directory does not exist: ${reportsDir}`); + process.exit(1); + } + + // Try to load inventory if available to match policies + let inventory; + try { + inventory = await collectInventory(resolvedRoot); + } catch { + // If inventory fails to load (e.g. running outside repo), proceed without it + } + + const summary = await summarizeReports(reportsDir, inventory); + + if (jsonMode) { + console.log(formatReportSummaryJson(summary, resolvedRoot)); + } else { + console.log(formatReportSummary(summary, resolvedRoot)); + } +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/eval-validate-cli.ts b/scripts/eval-validate-cli.ts new file mode 100644 index 0000000000000000000000000000000000000000..2513fc2e08fe197cc4aeb4c34d6709864cd7a748 --- /dev/null +++ b/scripts/eval-validate-cli.ts @@ -0,0 +1,94 @@ +#!/usr/bin/env tsx + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview CLI entry point for the eval validate command. + * + * Usage: + * npm run eval:validate + * npm run eval:validate -- --json + * npm run eval:validate -- --root /path/to/repo + * npm run eval:validate -- evals/some-file.eval.ts [--json] + */ + +import path from 'node:path'; +import { collectInventory } from './utils/eval-inventory.js'; +import { buildToolRegistry } from './utils/tool-registry.js'; +import { + validateInventory, + formatValidationReport, + formatValidationJson, +} from './utils/eval-validate.js'; + +async function main() { + const args = process.argv.slice(2); + + const rootFlagIndex = args.indexOf('--root'); + let repoRoot: string | undefined; + if (rootFlagIndex !== -1) { + repoRoot = args[rootFlagIndex + 1]; + if (repoRoot === undefined) { + console.error( + 'Error: --root requires a directory path argument but none was provided.', + ); + process.exit(1); + } + if (repoRoot.startsWith('--')) { + console.error( + `Error: --root value "${repoRoot}" looks like a flag. Provide a valid directory path.`, + ); + process.exit(1); + } + args.splice(rootFlagIndex, 2); + } + + const resolvedRoot = repoRoot ? path.resolve(repoRoot) : process.cwd(); + + const jsonFlagIndex = args.indexOf('--json'); + const jsonMode = jsonFlagIndex !== -1; + if (jsonMode) args.splice(jsonFlagIndex, 1); + + const filePaths = args.filter((a) => !a.startsWith('--')); + + const inventory = await collectInventory(resolvedRoot); + + if (inventory.totalFiles === 0) { + console.error('No eval files found under evals/.'); + process.exit(1); + } + + const registry = buildToolRegistry(); + const result = validateInventory(inventory, registry, { + filePaths: filePaths.length > 0 ? filePaths : undefined, + }); + + if (result.unmatchedFilePaths && result.unmatchedFilePaths.length > 0) { + console.error( + 'Error: The following requested file(s) were not found or did not contain any eval cases:', + ); + for (const f of result.unmatchedFilePaths) { + console.error(` - ${f}`); + } + process.exit(1); + } + + if (jsonMode) { + console.log(formatValidationJson(result, resolvedRoot)); + } else { + console.log(formatValidationReport(result, resolvedRoot)); + } + + if (result.totalViolations > 0) { + process.exit(1); + } +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/eval_utils.js b/scripts/eval_utils.js new file mode 100644 index 0000000000000000000000000000000000000000..6d13f1189129fedfc3ee4d2a87f65c7ba1e05d62 --- /dev/null +++ b/scripts/eval_utils.js @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import os from 'node:os'; + +/** + * Finds all report.json files recursively in a directory. + */ +export function findReports(dir) { + const reports = []; + if (!fs.existsSync(dir)) return reports; + + const files = fs.readdirSync(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + reports.push(...findReports(fullPath)); + } else if (file === 'report.json') { + reports.push(fullPath); + } + } + return reports; +} + +/** + * Extracts the model name from the artifact path. + */ +export function getModelFromPath(reportPath) { + const parts = reportPath.split(path.sep); + // Look for the directory that follows the 'eval-logs-' pattern + const artifactDir = parts.find((p) => p.startsWith('eval-logs-')); + if (!artifactDir) return 'unknown'; + + const match = artifactDir.match(/^eval-logs-(.+)-(\d+)$/); + return match ? match[1] : 'unknown'; +} + +/** + * Escapes special characters in a string for use in a regular expression. + */ +export function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Aggregates stats from a list of report.json files. + * @returns {Record>} statsByModel + */ +export function getStatsFromReports(reports) { + const statsByModel = {}; + + for (const reportPath of reports) { + try { + const model = getModelFromPath(reportPath); + if (!statsByModel[model]) { + statsByModel[model] = {}; + } + const testStats = statsByModel[model]; + + const content = fs.readFileSync(reportPath, 'utf-8'); + const json = JSON.parse(content); + + for (const testResult of json.testResults) { + const filePath = testResult.name; + for (const assertion of testResult.assertionResults) { + const name = assertion.title; + if (!testStats[name]) { + testStats[name] = { passed: 0, total: 0, file: filePath }; + } + testStats[name].total++; + if (assertion.status === 'passed') { + testStats[name].passed++; + } + } + } + } catch (error) { + console.error(`Error processing report at ${reportPath}:`, error.message); + } + } + return statsByModel; +} + +/** + * Fetches historical nightly data using the GitHub CLI. + * @returns {Array<{runId: string, stats: Record}>} history + */ +export function fetchNightlyHistory(lookbackCount) { + const history = []; + try { + const cmd = `gh run list --workflow evals-nightly.yml --branch main --limit ${ + lookbackCount + 2 + } --json databaseId,status`; + const runsJson = execSync(cmd, { encoding: 'utf-8' }); + let runs = JSON.parse(runsJson); + + // Filter for completed runs and take the top N + runs = runs.filter((r) => r.status === 'completed').slice(0, lookbackCount); + + for (const run of runs) { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), `gemini-evals-hist-${run.databaseId}-`), + ); + try { + execSync( + `gh run download ${run.databaseId} -p "eval-logs-*" -D "${tmpDir}"`, + { stdio: 'ignore' }, + ); + + const runReports = findReports(tmpDir); + if (runReports.length > 0) { + history.push({ + runId: run.databaseId, + stats: getStatsFromReports(runReports), + }); + } + } catch (error) { + console.error( + `Failed to process artifacts for run ${run.databaseId}:`, + error.message, + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + } + } catch (error) { + console.error('Failed to fetch history:', error.message); + } + return history; +} diff --git a/scripts/generate-git-commit-info.js b/scripts/generate-git-commit-info.js new file mode 100644 index 0000000000000000000000000000000000000000..8aafbf54112b0567f40b27a782bfaa410adaff71 --- /dev/null +++ b/scripts/generate-git-commit-info.js @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readPackageUp } from 'read-package-up'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); +const scriptPath = relative(root, fileURLToPath(import.meta.url)); +const generatedCliDir = join(root, 'packages/cli/src/generated'); +const cliGitCommitFile = join(generatedCliDir, 'git-commit.ts'); +const generatedCoreDir = join(root, 'packages/core/src/generated'); +const coreGitCommitFile = join(generatedCoreDir, 'git-commit.ts'); +let gitCommitInfo = 'N/A'; +let cliVersion = 'UNKNOWN'; + +if (!existsSync(generatedCliDir)) { + mkdirSync(generatedCliDir, { recursive: true }); +} + +if (!existsSync(generatedCoreDir)) { + mkdirSync(generatedCoreDir, { recursive: true }); +} + +try { + // Check for GIT_COMMIT env var first (e.g. when building inside Docker + // without a .git directory available) + const envCommit = process.env.GIT_COMMIT; + if (envCommit && /^[0-9a-f]+$/i.test(envCommit)) { + gitCommitInfo = envCommit; + } else { + const gitHash = execSync('git rev-parse --short HEAD', { + encoding: 'utf-8', + }).trim(); + if (gitHash) { + gitCommitInfo = gitHash; + } + } + const result = await readPackageUp(); + cliVersion = result?.packageJson?.version ?? 'UNKNOWN'; +} catch { + // ignore +} + +const fileContent = `/** + * @license + * Copyright ${new Date().getUTCFullYear()} Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// This file is auto-generated by the build script (${scriptPath}) +// Do not edit this file manually. +export const GIT_COMMIT_INFO = '${gitCommitInfo}'; +export const CLI_VERSION = '${cliVersion}'; +`; + +writeFileSync(cliGitCommitFile, fileContent); +writeFileSync(coreGitCommitFile, fileContent); diff --git a/scripts/generate-settings-doc.ts b/scripts/generate-settings-doc.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d27eb962a9e18044b8a0d42b89692e5e9f074ec --- /dev/null +++ b/scripts/generate-settings-doc.ts @@ -0,0 +1,285 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { readFile, writeFile } from 'node:fs/promises'; +import { generateSettingsSchema } from './generate-settings-schema.js'; +import { + escapeBackticks, + formatDefaultValue, + formatWithPrettier, + injectBetweenMarkers, + normalizeForCompare, +} from './utils/autogen.js'; + +import type { + SettingDefinition, + SettingsSchema, + SettingsSchemaType, +} from '../packages/cli/src/config/settingsSchema.js'; + +const START_MARKER = ''; +const END_MARKER = ''; + +const MANUAL_TOP_LEVEL = new Set(['mcpServers', 'telemetry', 'extensions']); + +interface DocEntry { + path: string; + type: string; + label: string; + category: string; + description: string; + defaultValue: string; + requiresRestart: boolean; + enumValues?: string[]; +} + +export async function main(argv = process.argv.slice(2)) { + const checkOnly = argv.includes('--check'); + + await generateSettingsSchema({ checkOnly }); + + const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + ); + const docPath = path.join(repoRoot, 'docs/reference/configuration.md'); + const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md'); + + const { getSettingsSchema } = await loadSettingsSchemaModule(); + const schema = getSettingsSchema(); + const allSettingsSections = collectEntries(schema, { includeAll: true }); + const filteredSettingsSections = collectEntries(schema, { + includeAll: false, + }); + + const generatedBlock = renderSections(allSettingsSections); + const generatedTableBlock = renderTableSections(filteredSettingsSections); + + await updateFile(docPath, generatedBlock, checkOnly); + await updateFile(cliSettingsDocPath, generatedTableBlock, checkOnly); +} + +async function updateFile( + filePath: string, + newContent: string, + checkOnly: boolean, +) { + const doc = await readFile(filePath, 'utf8'); + const injectedDoc = injectBetweenMarkers({ + document: doc, + startMarker: START_MARKER, + endMarker: END_MARKER, + newContent: newContent, + paddingBefore: '\n', + paddingAfter: '\n', + }); + const formattedDoc = await formatWithPrettier(injectedDoc, filePath); + + if (normalizeForCompare(doc) === normalizeForCompare(formattedDoc)) { + if (!checkOnly) { + console.log( + `Settings documentation (${path.basename(filePath)}) already up to date.`, + ); + } + return; + } + + if (checkOnly) { + console.error( + 'Settings documentation (' + + path.basename(filePath) + + ') is out of date. Run `npm run docs:settings` to regenerate.', + ); + process.exitCode = 1; + return; + } + + await writeFile(filePath, formattedDoc); + console.log( + `Settings documentation (${path.basename(filePath)}) regenerated.`, + ); +} + +async function loadSettingsSchemaModule() { + const modulePath = '../packages/cli/src/config/settingsSchema.ts'; + return import(modulePath); +} + +function collectEntries( + schema: SettingsSchemaType, + options: { includeAll?: boolean } = {}, +) { + const sections = new Map(); + + const visit = ( + current: SettingsSchema, + pathSegments: string[], + topLevel?: string, + ) => { + for (const [key, definition] of Object.entries(current)) { + if (pathSegments.length === 0 && MANUAL_TOP_LEVEL.has(key)) { + continue; + } + + const newPathSegments = [...pathSegments, key]; + const sectionKey = topLevel ?? key; + const hasChildren = + definition.type === 'object' && + definition.properties && + Object.keys(definition.properties).length > 0; + + if (definition.ignoreInDocs) { + continue; + } + + if (!hasChildren && (options.includeAll || definition.showInDialog)) { + if (!sections.has(sectionKey)) { + sections.set(sectionKey, []); + } + + sections.get(sectionKey)!.push({ + path: newPathSegments.join('.'), + type: formatType(definition), + label: definition.label, + category: definition.category, + description: formatDescription(definition), + defaultValue: formatDefaultValue(definition.default, { + quoteStrings: true, + }), + requiresRestart: Boolean(definition.requiresRestart), + enumValues: definition.options?.map((option) => + formatDefaultValue(option.value, { quoteStrings: true }), + ), + }); + } + + if (hasChildren && definition.properties) { + visit(definition.properties, newPathSegments, sectionKey); + } + } + }; + + visit(schema, []); + return sections; +} + +function formatDescription(definition: SettingDefinition) { + if (definition.description?.trim()) { + return definition.description.trim(); + } + return 'Description not provided.'; +} + +function formatType(definition: SettingDefinition): string { + switch (definition.ref) { + case 'StringOrStringArray': + return 'string | string[]'; + case 'BooleanOrString': + return 'boolean | string'; + default: + return definition.type; + } +} + +function renderSections(sections: Map) { + const lines: string[] = []; + + for (const [section, entries] of sections) { + if (entries.length === 0) { + continue; + } + + lines.push('#### `' + section + '`'); + lines.push(''); + + for (const entry of entries) { + lines.push('- **`' + entry.path + '`** (' + entry.type + '):'); + lines.push(' - **Description:** ' + entry.description); + + if (entry.defaultValue.includes('\n')) { + lines.push(' - **Default:**'); + lines.push(''); + lines.push(' ```json'); + lines.push( + entry.defaultValue + .split('\n') + .map((line) => ' ' + line) + .join('\n'), + ); + lines.push(' ```'); + } else { + lines.push( + ' - **Default:** `' + escapeBackticks(entry.defaultValue) + '`', + ); + } + + if (entry.enumValues && entry.enumValues.length > 0) { + const values = entry.enumValues + .map((value) => '`' + escapeBackticks(value) + '`') + .join(', '); + lines.push(' - **Values:** ' + values); + } + + if (entry.requiresRestart) { + lines.push(' - **Requires restart:** Yes'); + } + + lines.push(''); + } + } + + return lines.join('\n').trimEnd(); +} + +function renderTableSections(sections: Map) { + const lines: string[] = []; + + for (const [section, entries] of sections) { + if (entries.length === 0) { + continue; + } + + let title = section.charAt(0).toUpperCase() + section.slice(1); + if (title === 'Ui') { + title = 'UI'; + } else if (title === 'Ide') { + title = 'IDE'; + } + lines.push(`### ${title}`); + lines.push(''); + lines.push('| UI Label | Setting | Description | Default |'); + lines.push('| --- | --- | --- | --- |'); + + for (const entry of entries) { + const val = entry.defaultValue.replace(/\n/g, ' '); + const defaultVal = '`' + escapeBackticks(val) + '`'; + lines.push( + '| ' + + entry.label + + ' | `' + + entry.path + + '` | ' + + entry.description + + ' | ' + + defaultVal + + ' |', + ); + } + + lines.push(''); + } + + return lines.join('\n').trimEnd(); +} + +if (process.argv[1]) { + const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href; + if (entryUrl === import.meta.url) { + await main(); + } +} diff --git a/scripts/generate-settings-schema.ts b/scripts/generate-settings-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ec5d9741c773dd0900d19705fd9de4c891f017d --- /dev/null +++ b/scripts/generate-settings-schema.ts @@ -0,0 +1,362 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +import { + getSettingsSchema, + type SettingCollectionDefinition, + type SettingDefinition, + type SettingsSchema, + type SettingsSchemaType, + SETTINGS_SCHEMA_DEFINITIONS, + type SettingsJsonSchemaDefinition, +} from '../packages/cli/src/config/settingsSchema.js'; +import { + formatDefaultValue, + formatWithPrettier, + normalizeForCompare, +} from './utils/autogen.js'; + +const OUTPUT_RELATIVE_PATH = ['schemas', 'settings.schema.json']; +const SCHEMA_ID = + 'https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json'; + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +interface JsonSchema { + [key: string]: JsonValue | JsonSchema | JsonSchema[] | undefined; + $schema?: string; + $id?: string; + title?: string; + description?: string; + markdownDescription?: string; + type?: string | string[]; + enum?: JsonPrimitive[]; + default?: JsonValue; + properties?: Record; + items?: JsonSchema; + additionalProperties?: boolean | JsonSchema; + required?: string[]; + $ref?: string; + anyOf?: JsonSchema[]; +} + +interface GenerateOptions { + checkOnly: boolean; +} + +export async function generateSettingsSchema( + options: GenerateOptions, +): Promise { + const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + ); + const outputPath = path.join(repoRoot, ...OUTPUT_RELATIVE_PATH); + await mkdir(path.dirname(outputPath), { recursive: true }); + + const schemaObject = buildSchemaObject(getSettingsSchema()); + const formatted = await formatWithPrettier( + JSON.stringify(schemaObject, null, 2), + outputPath, + ); + + let existing: string | undefined; + try { + existing = await readFile(outputPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + if ( + existing && + normalizeForCompare(existing) === normalizeForCompare(formatted) + ) { + if (!options.checkOnly) { + console.log('Settings JSON schema already up to date.'); + } + return; + } + + if (options.checkOnly) { + console.error( + 'Settings JSON schema is out of date. Run `npm run schema:settings` to regenerate.', + ); + process.exitCode = 1; + return; + } + + await writeFile(outputPath, formatted); + console.log('Settings JSON schema regenerated.'); +} + +export async function main(argv = process.argv.slice(2)): Promise { + const checkOnly = argv.includes('--check'); + await generateSettingsSchema({ checkOnly }); +} + +function buildSchemaObject(schema: SettingsSchemaType): JsonSchema { + const defs = new Map( + Object.entries(SETTINGS_SCHEMA_DEFINITIONS as Record), + ); + + const root: JsonSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: SCHEMA_ID, + title: 'Gemini CLI Settings', + description: + 'Configuration file schema for Gemini CLI settings. This schema enables IDE completion for `settings.json`.', + type: 'object', + additionalProperties: false, + properties: {}, + }; + + root.properties!['$schema'] = { + title: 'Schema', + description: + 'The URL of the JSON schema for this settings file. Used by editors for validation and autocompletion.', + type: 'string', + default: SCHEMA_ID, + }; + + for (const [key, definition] of Object.entries(schema)) { + root.properties![key] = buildSettingSchema(definition, [key], defs); + } + + if (defs.size > 0) { + root.$defs = Object.fromEntries(defs.entries()); + } + + return root; +} + +function buildSettingSchema( + definition: SettingDefinition, + pathSegments: string[], + defs: Map, +): JsonSchema { + const base: JsonSchema = { + title: definition.label, + description: definition.description, + markdownDescription: buildMarkdownDescription(definition), + }; + + if (definition.default !== undefined) { + base.default = definition.default as JsonValue; + } + + const schemaShape = definition.ref + ? buildRefSchema(definition.ref, defs) + : buildSchemaForType(definition, pathSegments, defs); + + return { ...base, ...schemaShape }; +} + +function buildCollectionSchema( + collection: SettingCollectionDefinition, + pathSegments: string[], + defs: Map, +): JsonSchema { + if (collection.ref) { + return buildRefSchema(collection.ref, defs); + } + return buildSchemaForType(collection, pathSegments, defs); +} + +function buildSchemaForType( + source: SettingDefinition | SettingCollectionDefinition, + pathSegments: string[], + defs: Map, +): JsonSchema { + switch (source.type) { + case 'boolean': + case 'string': + case 'number': + return { type: source.type }; + case 'enum': + return buildEnumSchema(source.options); + case 'array': { + const itemPath = [...pathSegments, '']; + const items = isSettingDefinition(source) + ? source.items + ? buildCollectionSchema(source.items, itemPath, defs) + : {} + : source.properties + ? buildInlineObjectSchema(source.properties, itemPath, defs) + : {}; + return { type: 'array', items }; + } + case 'object': + return isSettingDefinition(source) + ? buildObjectDefinitionSchema(source, pathSegments, defs) + : buildObjectCollectionSchema(source, pathSegments, defs); + default: + return {}; + } +} + +function buildEnumSchema( + options: + | SettingDefinition['options'] + | SettingCollectionDefinition['options'], +): JsonSchema { + const values = options?.map((option) => option.value) ?? []; + const inferred = inferTypeFromValues(values); + return { + type: inferred ?? undefined, + enum: values, + }; +} + +function buildObjectDefinitionSchema( + definition: SettingDefinition, + pathSegments: string[], + defs: Map, +): JsonSchema { + const properties = definition.properties + ? buildObjectProperties(definition.properties, pathSegments, defs) + : undefined; + + const schema: JsonSchema = { + type: 'object', + }; + + if (properties && Object.keys(properties).length > 0) { + schema.properties = properties; + } + + if (definition.additionalProperties) { + schema.additionalProperties = buildCollectionSchema( + definition.additionalProperties, + [...pathSegments, ''], + defs, + ); + } else if (!definition.properties) { + schema.additionalProperties = true; + } else { + schema.additionalProperties = false; + } + + return schema; +} + +function buildObjectCollectionSchema( + collection: SettingCollectionDefinition, + pathSegments: string[], + defs: Map, +): JsonSchema { + if (collection.properties) { + return buildInlineObjectSchema(collection.properties, pathSegments, defs); + } + return { type: 'object', additionalProperties: true }; +} + +function buildObjectProperties( + properties: SettingsSchema, + pathSegments: string[], + defs: Map, +): Record { + const result: Record = {}; + for (const [childKey, childDefinition] of Object.entries(properties)) { + result[childKey] = buildSettingSchema( + childDefinition, + [...pathSegments, childKey], + defs, + ); + } + return result; +} + +function buildInlineObjectSchema( + properties: SettingsSchema, + pathSegments: string[], + defs: Map, +): JsonSchema { + const childSchemas = buildObjectProperties(properties, pathSegments, defs); + return { + type: 'object', + properties: childSchemas, + additionalProperties: false, + }; +} + +function buildRefSchema( + ref: string, + defs: Map, +): JsonSchema { + ensureDefinition(ref, defs); + return { $ref: `#/$defs/${ref}` }; +} + +function isSettingDefinition( + source: SettingDefinition | SettingCollectionDefinition, +): source is SettingDefinition { + return 'label' in source; +} + +function buildMarkdownDescription(definition: SettingDefinition): string { + const lines: string[] = []; + + if (definition.description?.trim()) { + lines.push(definition.description.trim()); + } else { + lines.push('Description not provided.'); + } + + lines.push(''); + lines.push(`- Category: \`${definition.category}\``); + lines.push( + `- Requires restart: \`${definition.requiresRestart ? 'yes' : 'no'}\``, + ); + + if (definition.default !== undefined) { + lines.push(`- Default: \`${formatDefaultValue(definition.default)}\``); + } + + return lines.join('\n'); +} + +function inferTypeFromValues( + values: Array, +): string | undefined { + if (values.length === 0) { + return undefined; + } + if (values.every((value) => typeof value === 'string')) { + return 'string'; + } + if (values.every((value) => typeof value === 'number')) { + return 'number'; + } + return undefined; +} + +function ensureDefinition(ref: string, defs: Map): void { + if (defs.has(ref)) { + return; + } + const predefined = SETTINGS_SCHEMA_DEFINITIONS[ref] as + | SettingsJsonSchemaDefinition + | undefined; + if (predefined) { + defs.set(ref, predefined as JsonSchema); + } else { + defs.set(ref, { description: `Definition for ${ref}` }); + } +} + +if (process.argv[1]) { + const entryUrl = pathToFileURL(path.resolve(process.argv[1])).href; + if (entryUrl === import.meta.url) { + await main(); + } +} diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js new file mode 100644 index 0000000000000000000000000000000000000000..e1cbf797e832bbd39cfe4f2f8d9f6c2e267a1a3d --- /dev/null +++ b/scripts/get-release-version.js @@ -0,0 +1,523 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import semver from 'semver'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +const TAG_LATEST = 'latest'; +const TAG_NIGHTLY = 'nightly'; +const TAG_PREVIEW = 'preview'; + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +function getArgs() { + return yargs(hideBin(process.argv)) + .option('type', { + description: 'The type of release to generate a version for.', + choices: [TAG_NIGHTLY, 'promote-nightly', 'stable', TAG_PREVIEW, 'patch'], + default: TAG_NIGHTLY, + }) + .option('patch-from', { + description: 'When type is "patch", specifies the source branch.', + choices: ['stable', TAG_PREVIEW], + string: true, + }) + .option('stable_version_override', { + description: 'Override the calculated stable version.', + string: true, + }) + .option('cli-package-name', { + description: + 'fully qualified package name with scope (e.g @google/gemini-cli)', + string: true, + default: '@google/gemini-cli', + }) + .option('preview_version_override', { + description: 'Override the calculated preview version.', + string: true, + }) + .option('stable-base-version', { + description: 'Base version to use for calculating next preview/nightly.', + string: true, + }) + .help(false) + .version(false) + .parse(); +} + +function getLatestTag(pattern) { + const command = `git tag -l '${pattern}'`; + try { + const tags = execSync(command) + .toString() + .trim() + .split('\n') + .filter(Boolean); + if (tags.length === 0) return ''; + + // Convert tags to versions (remove 'v' prefix) and sort by semver + const versions = tags + .map((tag) => tag.replace(/^v/, '')) + .filter((version) => semver.valid(version)) + .sort((a, b) => semver.rcompare(a, b)); // rcompare for descending order + + if (versions.length === 0) return ''; + + // Return the latest version with 'v' prefix restored + return `v${versions[0]}`; + } catch (error) { + console.error( + `Failed to get latest git tag for pattern "${pattern}": ${error.message}`, + ); + return ''; + } +} + +function getVersionFromNPM({ args, npmDistTag } = {}) { + const command = `npm view ${args['cli-package-name']} version --tag=${npmDistTag}`; + try { + return execSync(command).toString().trim(); + } catch (error) { + console.error( + `Failed to get NPM version for dist-tag "${npmDistTag}": ${error.message}`, + ); + return ''; + } +} + +function getAllVersionsFromNPM({ args } = {}) { + const command = `npm view ${args['cli-package-name']} versions --json`; + try { + const versionsJson = execSync(command).toString().trim(); + return JSON.parse(versionsJson); + } catch (error) { + console.error(`Failed to get all NPM versions: ${error.message}`); + return []; + } +} + +function isVersionDeprecated({ args, version } = {}) { + const command = `npm view ${args['cli-package-name']}@${version} deprecated`; + try { + const output = execSync(command).toString().trim(); + return output.length > 0; + } catch (error) { + // This command shouldn't fail for existing versions, but as a safeguard: + console.error( + `Failed to check deprecation status for ${version}: ${error.message}`, + ); + return false; // Assume not deprecated on error to avoid breaking the release. + } +} + +function detectRollbackAndGetBaseline({ args, npmDistTag } = {}) { + // Get the current dist-tag version + const distTagVersion = getVersionFromNPM({ args, npmDistTag }); + if (!distTagVersion) return { baseline: '', isRollback: false }; + + // Get all published versions + const allVersions = getAllVersionsFromNPM({ args }); + if (allVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + // Filter versions by type to match the dist-tag + let matchingVersions; + if (npmDistTag === TAG_LATEST) { + // Stable versions: no prerelease identifiers + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && !semver.prerelease(v), + ); + } else if (npmDistTag === TAG_PREVIEW) { + // Preview versions: contain -preview + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && v.includes('-preview'), + ); + } else if (npmDistTag === TAG_NIGHTLY) { + // Nightly versions: contain -nightly + matchingVersions = allVersions.filter( + (v) => semver.valid(v) && v.includes('-nightly'), + ); + } else { + // For other dist-tags, just use the dist-tag version + return { baseline: distTagVersion, isRollback: false }; + } + + if (matchingVersions.length === 0) + return { baseline: distTagVersion, isRollback: false }; + + // Sort by semver to get a list from highest to lowest + matchingVersions.sort((a, b) => semver.rcompare(a, b)); + + // Find the highest non-deprecated version with a git tag + let highestExistingVersion = ''; + for (const version of matchingVersions) { + if (!isVersionDeprecated({ version, args })) { + try { + // Only consider versions that have a corresponding git tag. + // This prevents picking up versions that were published to NPM but failed before the github release/tag. + let tagExists = false; + try { + execSync(`git rev-parse v${version}^{commit} 2>/dev/null`); + tagExists = true; + } catch { + const remoteTag = execSync( + `git ls-remote --tags origin refs/tags/v${version} 2>/dev/null`, + ) + .toString() + .trim(); + if (remoteTag) { + tagExists = true; + } + } + if (!tagExists) { + throw new Error(`Tag v${version} not found`); + } + highestExistingVersion = version; + break; // Found the one we want + } catch { + console.error( + `Ignoring version ${version} because it lacks a git tag (likely a failed release).`, + ); + } + } else { + console.error(`Ignoring deprecated version: ${version}`); + } + } + + // If all matching versions were deprecated, fall back to the dist-tag version + if (!highestExistingVersion) { + highestExistingVersion = distTagVersion; + } + + // Check if we're in a rollback scenario + const isRollback = semver.gt(highestExistingVersion, distTagVersion); + + return { + baseline: isRollback ? highestExistingVersion : distTagVersion, + isRollback, + distTagVersion, + highestExistingVersion, + }; +} + +function doesVersionExist({ args, version } = {}) { + // Check NPM + try { + const command = `npm view ${args['cli-package-name']}@${version} version 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === version) { + console.error(`Version ${version} already exists on NPM.`); + return true; + } + } catch { + // This is expected if the version doesn't exist. + } + + // Check Git tags + try { + const command = `git tag -l 'v${version}'`; + const tagOutput = execSync(command).toString().trim(); + if (tagOutput === `v${version}`) { + console.error(`Git tag v${version} already exists.`); + return true; + } + } catch (error) { + console.error(`Failed to check git tags for conflicts: ${error.message}`); + } + + // Check GitHub releases + try { + const command = `gh release view "v${version}" --json tagName --jq .tagName 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === `v${version}`) { + console.error(`GitHub release v${version} already exists.`); + return true; + } + } catch (error) { + const isExpectedNotFound = + error.message.includes('release not found') || + error.message.includes('Not Found') || + error.message.includes('not found') || + error.status === 1; + if (!isExpectedNotFound) { + console.error( + `Failed to check GitHub releases for conflicts: ${error.message}`, + ); + } + } + + return false; +} + +function getAndVerifyTags({ npmDistTag, args } = {}) { + // Detect rollback scenarios and get the correct baseline + const rollbackInfo = detectRollbackAndGetBaseline({ args, npmDistTag }); + const baselineVersion = rollbackInfo.baseline; + + if (!baselineVersion) { + throw new Error(`Unable to determine baseline version for ${npmDistTag}`); + } + + if (rollbackInfo.isRollback) { + // Rollback scenario: warn about the rollback but don't fail + console.error( + `Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation (highest existing version).`, + ); + } + + // Not verifying against git tags or GitHub releases as per user request. + + return { + latestVersion: baselineVersion, + latestTag: `v${baselineVersion}`, + }; +} + +function getStableBaseVersion(args) { + let latestStableVersion = args['stable-base-version']; + if (!latestStableVersion) { + const { latestVersion } = getAndVerifyTags({ + npmDistTag: TAG_LATEST, + args, + }); + latestStableVersion = latestVersion; + } + return latestStableVersion; +} + +function promoteNightlyVersion({ args } = {}) { + const latestStableVersion = getStableBaseVersion(args); + + const { latestTag: previousNightlyTag } = getAndVerifyTags({ + npmDistTag: TAG_NIGHTLY, + args, + }); + + const major = semver.major(latestStableVersion); + const minor = semver.minor(latestStableVersion); + const nextMinor = minor + 2; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim(); + return { + releaseVersion: `${major}.${nextMinor}.0-nightly.${date}.g${gitShortHash}`, + npmTag: TAG_NIGHTLY, + previousReleaseTag: previousNightlyTag, + }; +} + +function getNightlyVersion() { + const packageJson = readJson('package.json'); + const baseVersion = packageJson.version.split('-')[0]; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim(); + const releaseVersion = `${baseVersion}-nightly.${date}.g${gitShortHash}`; + const previousReleaseTag = getLatestTag('v*-nightly*'); + + return { + releaseVersion, + npmTag: TAG_NIGHTLY, + previousReleaseTag, + }; +} + +function validateVersion(version, format, name) { + const versionRegex = { + 'X.Y.Z': /^\d+\.\d+\.\d+$/, + 'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/, + }; + + if (!versionRegex[format] || !versionRegex[format].test(version)) { + throw new Error( + `Invalid ${name}: ${version}. Must be in ${format} format.`, + ); + } +} + +function getStableVersion(args) { + const { latestVersion: latestPreviewVersion } = getAndVerifyTags({ + npmDistTag: TAG_PREVIEW, + args, + }); + let releaseVersion; + if (args['stable_version_override']) { + const overrideVersion = args['stable_version_override'].replace(/^v/, ''); + validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override'); + releaseVersion = overrideVersion; + } else { + releaseVersion = latestPreviewVersion.replace(/-preview.*/, ''); + } + + const { latestTag: previousStableTag } = getAndVerifyTags({ + npmDistTag: TAG_LATEST, + args, + }); + + return { + releaseVersion, + npmTag: TAG_LATEST, + previousReleaseTag: previousStableTag, + }; +} + +function getPreviewVersion(args) { + const latestStableVersion = getStableBaseVersion(args); + + let releaseVersion; + if (args['preview_version_override']) { + const overrideVersion = args['preview_version_override'].replace(/^v/, ''); + validateVersion( + overrideVersion, + 'X.Y.Z-preview.N', + 'preview_version_override', + ); + releaseVersion = overrideVersion; + } else { + const major = semver.major(latestStableVersion); + const minor = semver.minor(latestStableVersion); + const nextMinor = minor + 1; + releaseVersion = `${major}.${nextMinor}.0-preview.0`; + } + + const { latestTag: previousPreviewTag } = getAndVerifyTags({ + npmDistTag: TAG_PREVIEW, + args, + }); + + return { + releaseVersion, + npmTag: TAG_PREVIEW, + previousReleaseTag: previousPreviewTag, + }; +} + +function getPatchVersion(args) { + const patchFrom = args['patch-from']; + if (!patchFrom || (patchFrom !== 'stable' && patchFrom !== TAG_PREVIEW)) { + throw new Error( + 'Patch type must be specified with --patch-from=stable or --patch-from=preview', + ); + } + const distTag = patchFrom === 'stable' ? TAG_LATEST : TAG_PREVIEW; + const { latestVersion, latestTag } = getAndVerifyTags({ + npmDistTag: distTag, + args, + }); + + if (patchFrom === 'stable') { + // For stable versions, increment the patch number: 0.5.4 -> 0.5.5 + const versionParts = latestVersion.split('.'); + const major = versionParts[0]; + const minor = versionParts[1]; + const patch = versionParts[2] ? parseInt(versionParts[2]) : 0; + const releaseVersion = `${major}.${minor}.${patch + 1}`; + return { + releaseVersion, + npmTag: distTag, + previousReleaseTag: latestTag, + }; + } else { + // For preview versions, increment the preview number: 0.6.0-preview.2 -> 0.6.0-preview.3 + const [version, prereleasePart] = latestVersion.split('-'); + if (!prereleasePart || !prereleasePart.startsWith('preview.')) { + throw new Error( + `Invalid preview version format: ${latestVersion}. Expected format like "0.6.0-preview.2"`, + ); + } + + const previewNumber = parseInt(prereleasePart.split('.')[1]); + if (isNaN(previewNumber)) { + throw new Error(`Could not parse preview number from: ${prereleasePart}`); + } + + const releaseVersion = `${version}-preview.${previewNumber + 1}`; + return { + releaseVersion, + npmTag: distTag, + previousReleaseTag: latestTag, + }; + } +} + +export function getVersion(options = {}) { + const args = { ...getArgs(), ...options }; + const type = args['type'] || TAG_NIGHTLY; // Nightly is the default. + + let versionData; + switch (type) { + case TAG_NIGHTLY: + versionData = getNightlyVersion(); + // Nightly versions include a git hash, so conflicts are highly unlikely + // and indicate a problem. We'll still validate but not auto-increment. + if (doesVersionExist({ args, version: versionData.releaseVersion })) { + throw new Error( + `Version conflict! Nightly version ${versionData.releaseVersion} already exists.`, + ); + } + break; + case 'promote-nightly': + versionData = promoteNightlyVersion({ args }); + // A promoted nightly version is still a nightly, so we should check for conflicts. + if (doesVersionExist({ args, version: versionData.releaseVersion })) { + throw new Error( + `Version conflict! Promoted nightly version ${versionData.releaseVersion} already exists.`, + ); + } + break; + case 'stable': + versionData = getStableVersion(args); + break; + case TAG_PREVIEW: + versionData = getPreviewVersion(args); + break; + case 'patch': + versionData = getPatchVersion(args); + break; + default: + throw new Error(`Unknown release type: ${type}`); + } + + // For patchable versions, check for existence and increment if needed. + if (type === 'stable' || type === TAG_PREVIEW || type === 'patch') { + let releaseVersion = versionData.releaseVersion; + while (doesVersionExist({ args, version: releaseVersion })) { + console.error(`Version ${releaseVersion} exists, incrementing.`); + if (releaseVersion.includes('-preview.')) { + // Increment preview number: 0.6.0-preview.2 -> 0.6.0-preview.3 + const [version, prereleasePart] = releaseVersion.split('-'); + const previewNumber = parseInt(prereleasePart.split('.')[1]); + releaseVersion = `${version}-preview.${previewNumber + 1}`; + } else { + // Increment patch number: 0.5.4 -> 0.5.5 + const versionParts = releaseVersion.split('.'); + const major = versionParts[0]; + const minor = versionParts[1]; + const patch = parseInt(versionParts[2]); + releaseVersion = `${major}.${minor}.${patch + 1}`; + } + } + versionData.releaseVersion = releaseVersion; + } + + // All checks are done, construct the final result. + const result = { + releaseTag: `v${versionData.releaseVersion}`, + ...versionData, + }; + + return result; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + console.log(JSON.stringify(getVersion(getArgs()), null, 2)); +} diff --git a/scripts/get_trustworthy_evals.js b/scripts/get_trustworthy_evals.js new file mode 100644 index 0000000000000000000000000000000000000000..c87d148e7a818fc986847918b0c65fdbf163b160 --- /dev/null +++ b/scripts/get_trustworthy_evals.js @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Identifies "Trustworthy" behavioral evaluations from nightly history. + * + * This script analyzes the last 6 days of nightly runs to find tests that meet + * strict stability criteria (80% aggregate pass rate and 60% daily floor). + * It outputs a list of files and a Vitest pattern used by the PR regression check + * to ensure high-signal validation and minimize noise. + */ + +import { fetchNightlyHistory, escapeRegex } from './eval_utils.js'; + +const LOOKBACK_COUNT = 6; +const MIN_VALID_RUNS = 5; // At least 5 out of 6 must be available +const PASS_RATE_THRESHOLD = 0.6; // Daily floor (e.g., 2/3) +const AGGREGATE_PASS_RATE_THRESHOLD = 0.8; // Weekly signal (e.g., 15/18) + +/** + * Main execution logic. + */ +function main() { + const targetModel = process.argv[2]; + if (!targetModel) { + console.error('โŒ Error: No target model specified.'); + process.exit(1); + } + console.error(`๐Ÿ” Identifying trustworthy evals for model: ${targetModel}`); + + const history = fetchNightlyHistory(LOOKBACK_COUNT); + if (history.length === 0) { + console.error('โŒ No historical data found.'); + process.exit(1); + } + + // Aggregate results for the target model across all history + const testHistories = {}; // { [testName]: { totalPassed: 0, totalRuns: 0, dailyRates: [], file: string } } + + for (const item of history) { + const modelStats = item.stats[targetModel]; + if (!modelStats) continue; + + for (const [testName, stat] of Object.entries(modelStats)) { + if (!testHistories[testName]) { + testHistories[testName] = { + totalPassed: 0, + totalRuns: 0, + dailyRates: [], + file: stat.file, + }; + } + testHistories[testName].totalPassed += stat.passed; + testHistories[testName].totalRuns += stat.total; + testHistories[testName].dailyRates.push(stat.passed / stat.total); + } + } + + const trustworthyTests = []; + const trustworthyFiles = new Set(); + const volatileTests = []; + const newTests = []; + + for (const [testName, info] of Object.entries(testHistories)) { + const dailyRates = info.dailyRates; + const aggregateRate = info.totalPassed / info.totalRuns; + + // 1. Minimum data points required + if (dailyRates.length < MIN_VALID_RUNS) { + newTests.push(testName); + continue; + } + + // 2. Trustworthy Criterion: + // - Every single day must be above the floor (e.g. > 60%) + // - The overall aggregate must be high-signal (e.g. > 80%) + const isDailyStable = dailyRates.every( + (rate) => rate > PASS_RATE_THRESHOLD, + ); + const isAggregateHighSignal = aggregateRate > AGGREGATE_PASS_RATE_THRESHOLD; + + if (isDailyStable && isAggregateHighSignal) { + trustworthyTests.push(testName); + if (info.file) { + const match = info.file.match(/evals\/.*\.eval\.ts/); + if (match) { + trustworthyFiles.add(match[0]); + } + } + } else { + volatileTests.push(testName); + } + } + + console.error( + `โœ… Found ${trustworthyTests.length} trustworthy tests across ${trustworthyFiles.size} files:`, + ); + trustworthyTests.sort().forEach((name) => console.error(` - ${name}`)); + console.error(`\nโšช Ignored ${volatileTests.length} volatile tests.`); + console.error( + `๐Ÿ†• Ignored ${newTests.length} tests with insufficient history.`, + ); + + // Output the list of names as a regex-friendly pattern for vitest -t + const pattern = trustworthyTests.map((name) => escapeRegex(name)).join('|'); + + // Also output unique file paths as a space-separated string + const files = Array.from(trustworthyFiles).join(' '); + + // Print the combined output to stdout for use in shell scripts (only if piped/CI) + if (!process.stdout.isTTY) { + // Format: FILE_LIST --test-pattern TEST_PATTERN + // This allows the workflow to easily use it + process.stdout.write(`${files} --test-pattern ${pattern || ''}\n`); + } else { + console.error( + '\n๐Ÿ’ก Note: Raw regex pattern and file list are hidden in interactive terminal. It will be printed when piped or in CI.', + ); + } +} + +main(); diff --git a/scripts/harvest_api_reliability.sh b/scripts/harvest_api_reliability.sh new file mode 100644 index 0000000000000000000000000000000000000000..383523912f898db38db12a0826fb15bc5066ea13 --- /dev/null +++ b/scripts/harvest_api_reliability.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +# Gemini API Reliability Harvester +# ------------------------------- +# This script gathers data about 500 API errors encountered during evaluation runs +# (eval.yml) from GitHub Actions. It is used to analyze developer friction caused +# by transient API failures. +# +# Usage: +# ./scripts/harvest_api_reliability.sh [SINCE] [LIMIT] [BRANCH] +# +# Examples: +# ./scripts/harvest_api_reliability.sh # Last 7 days, all branches +# ./scripts/harvest_api_reliability.sh 14d 500 # Last 14 days, limit 500 +# ./scripts/harvest_api_reliability.sh 2026-03-01 100 my-branch # Specific date and branch +# +# Prerequisites: +# - GitHub CLI (gh) installed and authenticated (`gh auth login`) +# - jq installed + +# Arguments & Defaults +if [[ -n "${1}" && "${1}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + SINCE="${1}" +elif [[ -n "${1}" && "${1}" =~ ^([0-9]+)d$ ]]; then + DAYS="${BASH_REMATCH[1]}" + os_type="$(uname || true)" + if [[ "${os_type}" == "darwin"* ]]; then + SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d) + else + SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d) + fi +else + # Default to 7 days ago in YYYY-MM-DD format (UTC) + os_type="$(uname || true)" + if [[ "${os_type}" == "darwin"* ]]; then + SINCE=$(date -u -v-7d +%Y-%m-%d) + else + SINCE=$(date -u -d "7 days ago" +%Y-%m-%d) + fi +fi + +LIMIT=${2:-300} +BRANCH=${3:-""} +WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly") +DEST_DIR="$(mktemp -d -t gemini-reliability-XXXXXX)" +MERGED_FILE="api-reliability-summary.jsonl" + +# Ensure cleanup on exit +trap 'rm -rf "${DEST_DIR}"' EXIT + +if ! command -v gh &> /dev/null; then + echo "โŒ Error: GitHub CLI (gh) is not installed." + exit 1 +fi + +if ! command -v jq &> /dev/null; then + echo "โŒ Error: jq is not installed." + exit 1 +fi + +# Clean start +rm -f "${MERGED_FILE}" + +# gh run list --created expects a date (YYYY-MM-DD) or a range +CREATED_QUERY=">=${SINCE}" + +for WORKFLOW in "${WORKFLOWS[@]}"; do + echo "๐Ÿ” Fetching runs for '${WORKFLOW}' created since ${SINCE} (max ${LIMIT} runs, branch: ${BRANCH:-all})..." + + # Construct arguments for gh run list + GH_ARGS=("--workflow" "${WORKFLOW}" "--created" "${CREATED_QUERY}" "--limit" "${LIMIT}" "--json" "databaseId" "--jq" ".[].databaseId") + if [[ -n "${BRANCH}" ]]; then + GH_ARGS+=("--branch" "${BRANCH}") + fi + + RUN_IDS=$(gh run list "${GH_ARGS[@]}") + exit_code=$? + + if [[ "${exit_code}" -ne 0 ]]; then + echo "โŒ Failed to fetch runs for '${WORKFLOW}' (exit code: ${exit_code}). Please check 'gh auth status' and permissions." >&2 + continue + fi + + if [[ -z "${RUN_IDS}" ]]; then + echo "๐Ÿ“ญ No runs found for workflow '${WORKFLOW}' since ${SINCE}." + continue + fi + + for ID in ${RUN_IDS}; do + # Download artifacts named 'eval-logs-*' + # Silencing output because many older runs won't have artifacts + gh run download "${ID}" -p "eval-logs-*" -D "${DEST_DIR}/${ID}" &>/dev/null || continue + + # Append to master log + # Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID + find "${DEST_DIR}/${ID}" -type f -name "api-reliability.jsonl" -exec cat {} + >> "${MERGED_FILE}" 2>/dev/null + done +done + +if [[ ! -f "${MERGED_FILE}" ]]; then + echo "๐Ÿ“ญ No reliability data found in the retrieved logs." + exit 0 +fi + +echo -e "\nโœ… Harvest Complete! Data merged into: ${MERGED_FILE}" +echo "------------------------------------------------" +echo "๐Ÿ“Š Gemini API Reliability Summary (Since ${SINCE})" +echo "------------------------------------------------" + +# shellcheck disable=SC2312 +cat "${MERGED_FILE}" | jq -s ' + group_by(.model) | map({ + model: .[0].model, + "500s": (map(select(.errorCode == "500")) | length), + "503s": (map(select(.errorCode == "503")) | length), + retries: (map(select(.status == "RETRY")) | length), + skips: (map(select(.status == "SKIP")) | length) + })' + +# shellcheck disable=SC2312 +echo -e "\n๐Ÿ’ก Total events captured: $(wc -l < "${MERGED_FILE}")" diff --git a/scripts/lint.js b/scripts/lint.js new file mode 100644 index 0000000000000000000000000000000000000000..0cf51cb8bad36c5865729e58fe437de8fa4edffd --- /dev/null +++ b/scripts/lint.js @@ -0,0 +1,518 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { + mkdirSync, + rmSync, + readFileSync, + existsSync, + lstatSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const ACTIONLINT_VERSION = '1.7.7'; +const SHELLCHECK_VERSION = '0.11.0'; +const YAMLLINT_VERSION = '1.35.1'; + +const TEMP_DIR = + process.env.GEMINI_LINT_TEMP_DIR || join(tmpdir(), 'gemini-cli-linters'); + +function getPlatformArch() { + const platform = process.platform; + const arch = process.arch; + if (platform === 'linux' && arch === 'x64') { + return { + actionlint: 'linux_amd64', + shellcheck: 'linux.x86_64', + }; + } + if (platform === 'darwin' && arch === 'x64') { + return { + actionlint: 'darwin_amd64', + shellcheck: 'darwin.x86_64', + }; + } + if (platform === 'darwin' && arch === 'arm64') { + return { + actionlint: 'darwin_arm64', + shellcheck: 'darwin.aarch64', + }; + } + if (platform === 'win32' && arch === 'x64') { + return { + actionlint: 'windows_amd64', + // shellcheck is not used for Windows since it uses the .zip release + // which has a consistent name across architectures + }; + } + throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`); +} + +const platformArch = getPlatformArch(); + +const PYTHON_VENV_PATH = join(TEMP_DIR, 'python_venv'); + +const pythonVenvPythonPath = join( + PYTHON_VENV_PATH, + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', +); + +const isWindows = process.platform === 'win32'; + +const actionlintCheck = isWindows + ? `where actionlint 2>nul` + : 'command -v actionlint'; + +const actionlintInstaller = isWindows + ? `powershell -Command "` + + `New-Item -ItemType Directory -Force -Path '${TEMP_DIR}/actionlint' | Out-Null; ` + + `Invoke-WebRequest -Uri 'https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.zip' -OutFile '${TEMP_DIR}/.actionlint.zip'; ` + + `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` + + `[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.actionlint.zip', '${TEMP_DIR}/actionlint')"` + : ` + mkdir -p "${TEMP_DIR}/actionlint" + curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz" + tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint" + `; + +const shellcheckCheck = isWindows + ? `where shellcheck 2>nul` + : 'command -v shellcheck'; + +const shellcheckInstaller = isWindows + ? `powershell -Command "` + + `Invoke-WebRequest -Uri 'https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.zip' -OutFile '${TEMP_DIR}/.shellcheck.zip'; ` + + `Add-Type -AssemblyName System.IO.Compression.FileSystem; ` + + `[System.IO.Compression.ZipFile]::ExtractToDirectory('${TEMP_DIR}/.shellcheck.zip', '${TEMP_DIR}/shellcheck')"` + : ` + mkdir -p "${TEMP_DIR}/shellcheck" + curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz" + tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1 + `; + +const yamllintCheck = isWindows + ? `if exist "${PYTHON_VENV_PATH}\\Scripts\\yamllint.exe" (exit 0) else (exit 1)` + : `test -x "${PYTHON_VENV_PATH}/bin/yamllint"`; + +const yamllintInstaller = isWindows + ? `python -m venv "${PYTHON_VENV_PATH}" && ` + + `"${pythonVenvPythonPath}" -m pip install --upgrade pip && ` + + `"${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple` + : ` + python3 -m venv "${PYTHON_VENV_PATH}" && \ + "${pythonVenvPythonPath}" -m pip install --upgrade pip && \ + "${pythonVenvPythonPath}" -m pip install "yamllint==${YAMLLINT_VERSION}" --index-url https://pypi.org/simple + `; + +/** + * @typedef {{ + * check: string; + * installer: string; + * run: string; + * }} + */ + +/** + * @type {{[linterName: string]: Linter}} + */ +const LINTERS = { + actionlint: { + check: actionlintCheck, + installer: actionlintInstaller, + run: ` + actionlint \ + -color \ + -ignore 'SC2002:' \ + -ignore 'SC2016:' \ + -ignore 'SC2129:' \ + -ignore 'label ".+" is unknown' + `, + }, + shellcheck: { + check: shellcheckCheck, + installer: shellcheckInstaller, + run: ` + git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \ + | grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \ + | xargs shellcheck \ + --check-sourced \ + --enable=all \ + --exclude=SC2002,SC2129,SC2310 \ + --severity=style \ + --format=gcc \ + --color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g' + `, + }, + yamllint: { + check: yamllintCheck, + installer: yamllintInstaller, + run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github", + }, +}; + +function runCommand(command, stdio = 'inherit') { + try { + const env = { ...process.env }; + const nodeBin = join(process.cwd(), 'node_modules', '.bin'); + const sep = isWindows ? ';' : ':'; + const pythonBin = isWindows + ? join(PYTHON_VENV_PATH, 'Scripts') + : join(PYTHON_VENV_PATH, 'bin'); + // Windows sometimes uses 'Path' instead of 'PATH' + const pathKey = 'Path' in env ? 'Path' : 'PATH'; + env[pathKey] = [ + nodeBin, + join(TEMP_DIR, 'actionlint'), + join(TEMP_DIR, 'shellcheck'), + pythonBin, + env[pathKey], + ].join(sep); + execSync(command, { stdio, env, shell: true }); + return true; + } catch { + return false; + } +} + +export function setupLinters() { + console.log('Setting up linters...'); + if (!process.env.GEMINI_LINT_TEMP_DIR) { + rmSync(TEMP_DIR, { recursive: true, force: true }); + } + mkdirSync(TEMP_DIR, { recursive: true }); + + for (const linter in LINTERS) { + const { check, installer } = LINTERS[linter]; + if (!runCommand(check, 'ignore')) { + console.log(`Installing ${linter}...`); + if (!runCommand(installer)) { + console.error( + `Failed to install ${linter}. Please install it manually.`, + ); + process.exit(1); + } + } + } + console.log('All required linters are available.'); +} + +export function runESLint() { + console.log('\nRunning ESLint...'); + if (!runCommand('npm run lint')) { + process.exit(1); + } +} + +export function runActionlint() { + console.log('\nRunning actionlint...'); + if (!runCommand(LINTERS.actionlint.run)) { + process.exit(1); + } +} + +export function runShellcheck() { + console.log('\nRunning shellcheck...'); + if (!runCommand(LINTERS.shellcheck.run)) { + process.exit(1); + } +} + +export function runYamllint() { + console.log('\nRunning yamllint...'); + if (!runCommand(LINTERS.yamllint.run)) { + process.exit(1); + } +} + +export function runPrettier() { + console.log('\nRunning Prettier...'); + if (!runCommand('prettier --check .')) { + console.log( + 'Prettier check failed. Please run "npm run format" to fix formatting issues.', + ); + process.exit(1); + } +} + +export function runSensitiveKeywordLinter() { + console.log('\nRunning sensitive keyword linter...'); + const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g; + const ALLOWED_KEYWORDS = new Set([ + 'gemini-3.1', + 'gemini-3', + 'gemini-3.0', + 'gemini-2.5', + 'gemini-2.0', + 'gemini-1.5', + 'gemini-1.0', + ]); + + function getChangedFiles() { + const baseRef = process.env.GITHUB_BASE_REF || 'main'; + try { + execSync(`git fetch origin ${baseRef}`); + const mergeBase = execSync(`git merge-base HEAD origin/${baseRef}`) + .toString() + .trim(); + return execSync(`git diff --name-only ${mergeBase}..HEAD`) + .toString() + .trim() + .split('\n') + .filter(Boolean); + } catch { + console.error(`Could not get changed files against origin/${baseRef}.`); + try { + console.log('Falling back to diff against HEAD~1'); + return execSync(`git diff --name-only HEAD~1..HEAD`) + .toString() + .trim() + .split('\n') + .filter(Boolean); + } catch { + console.error('Could not get changed files against HEAD~1 either.'); + process.exit(1); + } + } + } + + const changedFiles = getChangedFiles(); + let violationsFound = false; + + for (const file of changedFiles) { + if (!existsSync(file) || lstatSync(file).isDirectory()) { + continue; + } + const content = readFileSync(file, 'utf-8'); + const lines = content.split('\n'); + let match; + while ((match = SENSITIVE_PATTERN.exec(content)) !== null) { + const keyword = match[0]; + if (!ALLOWED_KEYWORDS.has(keyword)) { + violationsFound = true; + const matchIndex = match.index; + let lineNum = 0; + let charCount = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (charCount + line.length + 1 > matchIndex) { + lineNum = i + 1; + const colNum = matchIndex - charCount + 1; + console.log( + `::warning file=${file},line=${lineNum},col=${colNum}::Found sensitive keyword "${keyword}". Please make sure this change is appropriate to submit.`, + ); + break; + } + charCount += line.length + 1; // +1 for the newline + } + } + } + } + + if (!violationsFound) { + console.log('No sensitive keyword violations found.'); + } +} + +function stripJSONComments(json) { + return json.replace( + /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, + (m, g) => (g ? '' : m), + ); +} + +export function runTSConfigLinter() { + console.log('\nRunning tsconfig linter...'); + + let files = []; + try { + // Find all tsconfig.json files under packages/ using a git pathspec + files = execSync("git ls-files 'packages/**/tsconfig.json'") + .toString() + .trim() + .split('\n') + .filter(Boolean); + } catch (e) { + console.error('Error finding tsconfig.json files:', e.message); + process.exit(1); + } + + let hasError = false; + + for (const file of files) { + const tsconfigPath = join(process.cwd(), file); + if (!existsSync(tsconfigPath)) { + console.error(`Error: ${tsconfigPath} does not exist.`); + hasError = true; + continue; + } + + try { + const content = readFileSync(tsconfigPath, 'utf-8'); + const config = JSON.parse(stripJSONComments(content)); + + // Check if exclude exists and matches exactly + if (config.exclude) { + if (!Array.isArray(config.exclude)) { + console.error( + `Error: ${file} "exclude" must be an array. Found: ${JSON.stringify( + config.exclude, + )}`, + ); + hasError = true; + } else { + const allowedExclude = new Set(['node_modules', 'dist']); + const invalidExcludes = config.exclude.filter( + (item) => !allowedExclude.has(item), + ); + + if (invalidExcludes.length > 0) { + console.error( + `Error: ${file} "exclude" contains invalid items: ${JSON.stringify( + invalidExcludes, + )}. Only "node_modules" and "dist" are allowed.`, + ); + hasError = true; + } + } + } + } catch (error) { + console.error(`Error parsing ${tsconfigPath}: ${error.message}`); + hasError = true; + } + } + + if (hasError) { + process.exit(1); + } +} + +export function runGithubActionsPinningLinter() { + console.log('\nRunning GitHub Actions pinning linter...'); + + let files = []; + try { + files = execSync( + "git ls-files '.github/workflows/*.yml' '.github/workflows/*.yaml' '.github/actions/**/*.yml' '.github/actions/**/*.yaml'", + ) + .toString() + .trim() + .split('\n') + .filter(Boolean); + } catch (e) { + console.error('Error finding GitHub Actions workflow files:', e.message); + process.exit(1); + } + + let violationsFound = false; + // Improved regex to capture action name and ref, handling optional quotes and comments. + const USES_PATTERN = /uses:\s*['"]?([^@\s'"]+)@([^#\s'"]+)['"]?/; + const SHA_PATTERN = /^[0-9a-f]{40}$/i; + + for (const file of files) { + if (!existsSync(file) || lstatSync(file).isDirectory()) { + continue; + } + const content = readFileSync(file, 'utf-8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = line.match(USES_PATTERN); + if (match) { + const action = match[1]; + let ref = match[2]; + + // Clean up any trailing quotes that might have been captured + ref = ref.replace(/['"]$/, ''); + + // Skip local actions (starting with ./), docker actions, and explicit exclusions + if ( + action.startsWith('./') || + action.startsWith('docker://') || + line.includes('# github-actions-pinning:ignore') + ) { + continue; + } + + if (!SHA_PATTERN.test(ref)) { + violationsFound = true; + const lineNum = i + 1; + console.error( + `::error file=${file},line=${lineNum}::Action "${action}" uses "${ref}" instead of a 40-character SHA.`, + ); + } + } + } + } + + if (violationsFound) { + console.error(` +GitHub Actions pinning violations found. Please use exact commit hashes. + +To automatically fix these, you can use the "ratchet" tool (https://github.com/sethvargo/ratchet): + - Mac/Linux (Homebrew): brew install ratchet && ratchet pin .github/workflows/*.yml .github/actions/**/*.yml + - Other platforms: Download from GitHub releases and run "ratchet pin .github/workflows/*.yml .github/actions/**/*.yml" + +If you must use a tag, you can ignore this check by adding a comment (discouraged): + uses: some-action@v1 # github-actions-pinning:ignore +`); + process.exit(1); + } else { + console.log('No GitHub Actions pinning violations found.'); + } +} + +function main() { + const args = process.argv.slice(2); + + if (args.includes('--setup')) { + setupLinters(); + } + if (args.includes('--eslint')) { + runESLint(); + } + if (args.includes('--actionlint')) { + runActionlint(); + } + if (args.includes('--shellcheck')) { + runShellcheck(); + } + if (args.includes('--yamllint')) { + runYamllint(); + } + if (args.includes('--prettier')) { + runPrettier(); + } + if (args.includes('--sensitive-keywords')) { + runSensitiveKeywordLinter(); + } + if (args.includes('--tsconfig')) { + runTSConfigLinter(); + } + if (args.includes('--check-github-actions-pinning')) { + runGithubActionsPinningLinter(); + } + + if (args.length === 0) { + setupLinters(); + runESLint(); + runActionlint(); + runShellcheck(); + runYamllint(); + runPrettier(); + runSensitiveKeywordLinter(); + runTSConfigLinter(); + runGithubActionsPinningLinter(); + console.log('\nAll linting checks passed!'); + } +} + +main(); diff --git a/scripts/local_telemetry.js b/scripts/local_telemetry.js new file mode 100644 index 0000000000000000000000000000000000000000..b4cb47e56bfc0082cc02a18a70f57ef661062681 --- /dev/null +++ b/scripts/local_telemetry.js @@ -0,0 +1,219 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import { spawn, execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { + BIN_DIR, + OTEL_DIR, + ensureBinary, + fileExists, + manageTelemetrySettings, + registerCleanup, + waitForPort, +} from './telemetry_utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const OTEL_CONFIG_FILE = path.join(OTEL_DIR, 'collector-local.yaml'); +const OTEL_LOG_FILE = path.join(OTEL_DIR, 'collector.log'); +const JAEGER_LOG_FILE = path.join(OTEL_DIR, 'jaeger.log'); +const JAEGER_PORT = 16686; + +// This configuration is for the primary otelcol-contrib instance. +// It receives from the CLI on 4317, exports traces to Jaeger on 14317, +// and sends metrics/logs to the debug log. +const OTEL_CONFIG_CONTENT = ` +receivers: + otlp: + protocols: + grpc: + endpoint: "localhost:4317" +processors: + batch: + timeout: 1s +exporters: + otlp: + endpoint: "localhost:14317" + tls: + insecure: true + debug: + verbosity: detailed +service: + telemetry: + logs: + level: "debug" + metrics: + level: "none" + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [debug] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug] +`; + +async function main() { + // 1. Ensure binaries are available, downloading if necessary. + // Binaries are stored in the project's .gemini/otel/bin directory + // to avoid modifying the user's system. + if (!fileExists(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true }); + + const otelcolPath = await ensureBinary( + 'otelcol-contrib', + 'open-telemetry/opentelemetry-collector-releases', + (version, platform, arch, ext) => + `otelcol-contrib_${version}_${platform}_${arch}.${ext}`, + 'otelcol-contrib', + false, // isJaeger = false + ).catch((e) => { + console.error(`๐Ÿ›‘ Error getting otelcol-contrib: ${e.message}`); + return null; + }); + if (!otelcolPath) process.exit(1); + + const jaegerPath = await ensureBinary( + 'jaeger', + 'jaegertracing/jaeger', + (version, platform, arch, ext) => + `jaeger-${version}-${platform}-${arch}.${ext}`, + 'jaeger', + true, // isJaeger = true + ).catch((e) => { + console.error(`๐Ÿ›‘ Error getting jaeger: ${e.message}`); + return null; + }); + if (!jaegerPath) process.exit(1); + + // 2. Kill any existing processes to ensure a clean start. + console.log('๐Ÿงน Cleaning up old processes and logs...'); + try { + execSync('pkill -f "otelcol-contrib"'); + console.log('โœ… Stopped existing otelcol-contrib process.'); + } catch {} // eslint-disable-line no-empty + try { + execSync('pkill -f "jaeger"'); + console.log('โœ… Stopped existing jaeger process.'); + } catch {} // eslint-disable-line no-empty + try { + if (fileExists(OTEL_LOG_FILE)) fs.unlinkSync(OTEL_LOG_FILE); + console.log('โœ… Deleted old collector log.'); + } catch (e) { + if (e.code !== 'ENOENT') console.error(e); + } + try { + if (fileExists(JAEGER_LOG_FILE)) fs.unlinkSync(JAEGER_LOG_FILE); + console.log('โœ… Deleted old jaeger log.'); + } catch (e) { + if (e.code !== 'ENOENT') console.error(e); + } + + let jaegerProcess, collectorProcess; + let jaegerLogFd, collectorLogFd; + + const originalSandboxSetting = manageTelemetrySettings( + true, + 'http://localhost:4317', + 'local', + ); + + registerCleanup( + () => [jaegerProcess, collectorProcess], + () => [jaegerLogFd, collectorLogFd], + originalSandboxSetting, + ); + + if (!fileExists(OTEL_DIR)) fs.mkdirSync(OTEL_DIR, { recursive: true }); + fs.writeFileSync(OTEL_CONFIG_FILE, OTEL_CONFIG_CONTENT); + console.log('๐Ÿ“„ Wrote OTEL collector config.'); + + // Start Jaeger + console.log(`๐Ÿš€ Starting Jaeger service... Logs: ${JAEGER_LOG_FILE}`); + jaegerLogFd = fs.openSync(JAEGER_LOG_FILE, 'a'); + jaegerProcess = spawn( + jaegerPath, + ['--set=receivers.otlp.protocols.grpc.endpoint=localhost:14317'], + { stdio: ['ignore', jaegerLogFd, jaegerLogFd] }, + ); + console.log(`โณ Waiting for Jaeger to start (PID: ${jaegerProcess.pid})...`); + + try { + await waitForPort(JAEGER_PORT); + console.log(`โœ… Jaeger started successfully.`); + } catch { + console.error(`๐Ÿ›‘ Error: Jaeger failed to start on port ${JAEGER_PORT}.`); + if (jaegerProcess && jaegerProcess.pid) { + process.kill(jaegerProcess.pid, 'SIGKILL'); + } + if (fileExists(JAEGER_LOG_FILE)) { + console.error('๐Ÿ“„ Jaeger Log Output:'); + console.error(fs.readFileSync(JAEGER_LOG_FILE, 'utf-8')); + } + process.exit(1); + } + + // Start the primary OTEL collector + console.log(`๐Ÿš€ Starting OTEL collector... Logs: ${OTEL_LOG_FILE}`); + collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a'); + collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], { + stdio: ['ignore', collectorLogFd, collectorLogFd], + }); + console.log( + `โณ Waiting for OTEL collector to start (PID: ${collectorProcess.pid})...`, + ); + + try { + await waitForPort(4317); + console.log(`โœ… OTEL collector started successfully.`); + } catch { + console.error(`๐Ÿ›‘ Error: OTEL collector failed to start on port 4317.`); + if (collectorProcess && collectorProcess.pid) { + process.kill(collectorProcess.pid, 'SIGKILL'); + } + if (fileExists(OTEL_LOG_FILE)) { + console.error('๐Ÿ“„ OTEL Collector Log Output:'); + console.error(fs.readFileSync(OTEL_LOG_FILE, 'utf-8')); + } + process.exit(1); + } + + [jaegerProcess, collectorProcess].forEach((proc) => { + if (proc) { + proc.on('error', (err) => { + console.error(`${proc.spawnargs[0]} process error:`, err); + process.exit(1); + }); + } + }); + + console.log(` +โœจ Local telemetry environment is running.`); + console.log( + ` +๐Ÿ”Ž View traces in the Jaeger UI: http://localhost:${JAEGER_PORT}`, + ); + console.log(`๐Ÿ“Š View metrics in the logs and metrics: ${OTEL_LOG_FILE}`); + console.log( + ` +๐Ÿ“„ Tail logs and metrics in another terminal: tail -f ${OTEL_LOG_FILE}`, + ); + console.log(` +Press Ctrl+C to exit.`); +} + +main(); diff --git a/scripts/pre-commit.js b/scripts/pre-commit.js new file mode 100644 index 0000000000000000000000000000000000000000..e748ac23d6455b747e37852ea9028e3b587a0e69 --- /dev/null +++ b/scripts/pre-commit.js @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import lintStaged from 'lint-staged'; + +try { + // Get repository root + const root = execSync('git rev-parse --show-toplevel').toString().trim(); + + // Run lint-staged with API directly + const passed = await lintStaged({ cwd: root }); + + // Exit with appropriate code + process.exit(passed ? 0 : 1); +} catch { + // Exit with error code + process.exit(1); +} diff --git a/scripts/prepare-github-release.js b/scripts/prepare-github-release.js new file mode 100644 index 0000000000000000000000000000000000000000..25ff5c4d19a714bf3ac58bfa1df45217dd3dc9fd --- /dev/null +++ b/scripts/prepare-github-release.js @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const rootDir = process.cwd(); + +function updatePackageJson(packagePath, updateFn) { + const packageJsonPath = path.resolve(rootDir, packagePath); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + updateFn(packageJson); + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); +} + +// Copy bundle directory into packages/cli +const sourceBundleDir = path.resolve(rootDir, 'bundle'); +const destBundleDir = path.resolve(rootDir, 'packages/cli/bundle'); + +if (fs.existsSync(sourceBundleDir)) { + fs.rmSync(destBundleDir, { recursive: true, force: true }); + fs.cpSync(sourceBundleDir, destBundleDir, { recursive: true }); + console.log('Copied bundle/ directory to packages/cli/'); +} else { + console.error( + 'Error: bundle/ directory not found at project root. Please run `npm run bundle` first.', + ); + process.exit(1); +} + +// Overwrite the .npmrc in the core package to point to the GitHub registry. +const coreNpmrcPath = path.resolve(rootDir, 'packages/core/.npmrc'); +fs.writeFileSync( + coreNpmrcPath, + '@google-gemini:registry=https://npm.pkg.github.com/', +); +console.log('Wrote .npmrc for @google-gemini scope to packages/core/'); + +// Update @google/gemini-cli +updatePackageJson('packages/cli/package.json', (pkg) => { + pkg.name = '@google-gemini/gemini-cli'; + pkg.files = ['bundle/']; + pkg.bin = { + gemini: 'bundle/gemini.js', + }; + + // Remove fields that are not relevant to the bundled package. + delete pkg.dependencies; + delete pkg.devDependencies; + delete pkg.scripts; + delete pkg.main; + delete pkg.config; // Deletes the sandboxImageUri +}); + +// Update @google/gemini-cli-a2a-server +updatePackageJson('packages/a2a-server/package.json', (pkg) => { + pkg.name = '@google-gemini/gemini-cli-a2a-server'; +}); + +// Update @google/gemini-cli-core +updatePackageJson('packages/core/package.json', (pkg) => { + pkg.name = '@google-gemini/gemini-cli-core'; +}); + +console.log('Successfully prepared packages for GitHub release.'); diff --git a/scripts/prepare-npm-release.js b/scripts/prepare-npm-release.js new file mode 100644 index 0000000000000000000000000000000000000000..6775b23dfbd1e710d4b0c0d47422210697e8797c --- /dev/null +++ b/scripts/prepare-npm-release.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const rootDir = process.cwd(); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(path.resolve(rootDir, filePath), 'utf-8')); +} + +function writeJson(filePath, data) { + fs.writeFileSync( + path.resolve(rootDir, filePath), + JSON.stringify(data, null, 2), + ); +} + +// Copy bundle directory into packages/cli +const sourceBundleDir = path.resolve(rootDir, 'bundle'); +const destBundleDir = path.resolve(rootDir, 'packages/cli/bundle'); + +if (fs.existsSync(sourceBundleDir)) { + fs.rmSync(destBundleDir, { recursive: true, force: true }); + fs.cpSync(sourceBundleDir, destBundleDir, { recursive: true }); + console.log('Copied bundle/ directory to packages/cli/'); +} else { + console.error( + 'Error: bundle/ directory not found at project root. Please run `npm run bundle` first.', + ); + process.exit(1); +} + +// Inherit optionalDependencies from root package.json, excluding dev-only packages. +const rootPkg = readJson('package.json'); +const optionalDependencies = { ...(rootPkg.optionalDependencies || {}) }; +delete optionalDependencies['gemini-cli-devtools']; + +// Update @google/gemini-cli package.json for bundled npm release +const cliPkgPath = 'packages/cli/package.json'; +const cliPkg = readJson(cliPkgPath); + +cliPkg.files = ['bundle/']; +cliPkg.bin = { + gemini: 'bundle/gemini.js', +}; + +delete cliPkg.dependencies; +delete cliPkg.devDependencies; +delete cliPkg.scripts; +delete cliPkg.main; +delete cliPkg.config; + +cliPkg.optionalDependencies = optionalDependencies; + +writeJson(cliPkgPath, cliPkg); + +console.log('Updated packages/cli/package.json for bundled npm release.'); +console.log( + 'optionalDependencies:', + JSON.stringify(optionalDependencies, null, 2), +); +console.log('Successfully prepared packages for npm release.'); diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js new file mode 100644 index 0000000000000000000000000000000000000000..ff1dc137ffb0d4714eacf3665e8b782bd7b1c32a --- /dev/null +++ b/scripts/prepare-package.js @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// ES module equivalent of __dirname +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const rootDir = path.resolve(__dirname, '..'); + +function copyFiles(packageName, filesToCopy) { + const packageDir = path.resolve(rootDir, 'packages', packageName); + if (!fs.existsSync(packageDir)) { + console.error(`Error: Package directory not found at ${packageDir}`); + process.exit(1); + } + + console.log(`Preparing package: ${packageName}`); + for (const [source, dest] of Object.entries(filesToCopy)) { + const sourcePath = path.resolve(rootDir, source); + const destPath = path.resolve(packageDir, dest); + try { + fs.copyFileSync(sourcePath, destPath); + console.log(`Copied ${source} to packages/${packageName}/`); + } catch (err) { + console.error(`Error copying ${source}:`, err); + process.exit(1); + } + } +} + +// Prepare 'core' package +copyFiles('core', { + 'README.md': 'README.md', + LICENSE: 'LICENSE', + '.npmrc': '.npmrc', +}); + +// Prepare 'cli' package +copyFiles('cli', { + 'README.md': 'README.md', + LICENSE: 'LICENSE', +}); + +console.log('Successfully prepared all packages.'); diff --git a/scripts/relabel_issues.sh b/scripts/relabel_issues.sh new file mode 100644 index 0000000000000000000000000000000000000000..9e8a776440dcab9a8bf193a1ae995f2e28dc8efa --- /dev/null +++ b/scripts/relabel_issues.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# scripts/relabel_issues.sh +# Usage: ./scripts/relabel_issues.sh [repository] + +set -e +set -o pipefail + +OLD_LABEL="${1}" +NEW_LABEL="${2}" +REPO="${3:-google-gemini/gemini-cli}" + +if [[ -z "${OLD_LABEL}" ]] || [[ -z "${NEW_LABEL}" ]]; then + echo "Usage: $0 [repository]" + echo "Example: $0 'area/models' 'area/agent'" + exit 1 +fi + +echo "๐Ÿ” Searching for open issues in '${REPO}' with label '${OLD_LABEL}'..." + +# Fetch issues with the old label +ISSUES=$(gh issue list --repo "${REPO}" --label "${OLD_LABEL}" --state open --limit 1000 --json number,title) + +# Avoid masking return value +COUNT=$(jq '. | length' <<< "${ISSUES}") + +if [[ "${COUNT}" -eq 0 ]]; then + echo "โœ… No issues found with label '${OLD_LABEL}'." + exit 0 +fi + +echo "found ${COUNT} issues to relabel." + +# Iterate and update +echo "${ISSUES}" | jq -r '.[] | "\(.number) \(.title)"' | while read -r number title; do + echo "๐Ÿ”„ Processing #${number}: ${title}" + echo " - Removing: ${OLD_LABEL}" + echo " + Adding: ${NEW_LABEL}" + + gh issue edit "${number}" --repo "${REPO}" --add-label "${NEW_LABEL}" --remove-label "${OLD_LABEL}" + + echo " โœ… Done." +done + +echo "๐ŸŽ‰ All issues relabeled!" \ No newline at end of file diff --git a/scripts/review.sh b/scripts/review.sh new file mode 100644 index 0000000000000000000000000000000000000000..6e19592a9bb81315f37f3f8414cd78d8fe128dc3 --- /dev/null +++ b/scripts/review.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# scripts/review.sh +# +# Usage: ./scripts/review.sh [model] + +set -e + +if [[ -z "${1}" ]]; then + echo "Usage: ${0} [model]" + exit 1 +fi +pr="${1}" +model="${2:-gemini-3.1-pro-preview}" +REPO="google-gemini/gemini-cli" +REVIEW_DIR="${HOME}/git/review/gemini-cli" + +if [[ ! -d "${REVIEW_DIR}" ]]; then + echo "ERROR: Directory ${REVIEW_DIR} does not exist." + echo "" + echo "Please create a new gemini-cli clone at that directory to use for reviews." + echo "Instructions:" + echo " mkdir -p ~/git/review" + echo " cd ~/git/review" + echo " git clone https://github.com/google-gemini/gemini-cli.git" + exit 1 +fi + +# 1. Check if the PR exists before doing anything else +echo "review: Validating PR ${pr} on ${REPO}..." +if ! gh pr view "${pr}" -R "${REPO}" > /dev/null 2>&1; then + echo "ERROR: Could not find PR #${pr} in ${REPO}." + echo "Are you sure ${pr} is a Pull Request number and not an Issue number?" + exit 1 +fi + +echo "review: Opening PR ${pr} in browser..." +uname_out="$(uname || true)" +if [[ "${uname_out}" == "Darwin" ]]; then + open "https://github.com/${REPO}/pull/${pr}" || true +else + xdg-open "https://github.com/${REPO}/pull/${pr}" || true +fi + +echo "review: Changing directory to ${REVIEW_DIR}" +cd "${REVIEW_DIR}" || exit 1 + +# 2. Fetch latest main to ensure we have a clean starting point +echo "review: Fetching latest from origin..." +git fetch origin main + +# 3. Handle worktree creation +WORKTREE_PATH="pr_${pr}" +if [[ -d "${WORKTREE_PATH}" ]]; then + echo "review: Worktree directory ${WORKTREE_PATH} already exists." + # Check if it's actually a registered worktree + # shellcheck disable=SC2312 + if git worktree list | grep -q "${WORKTREE_PATH}"; then + echo "review: Reusing existing worktree..." + else + echo "review: Directory exists but is not a worktree. Cleaning up..." + rm -rf "${WORKTREE_PATH}" + fi +fi + +if [[ ! -d "${WORKTREE_PATH}" ]]; then + echo "review: Adding new worktree at ${WORKTREE_PATH}..." + # Create a detached worktree from origin/main + git worktree add --detach "${WORKTREE_PATH}" origin/main +fi + +echo "review: Changing directory to ${WORKTREE_PATH}" +cd "${WORKTREE_PATH}" || exit 1 + +# 4. Checkout the PR +echo "review: Cleaning worktree and checking out PR ${pr}..." +git reset --hard +git clean -fd +gh pr checkout "${pr}" --branch "review-${pr}" -f -R "${REPO}" + +# 5. Clean and Build +echo "review: Clearing possibly stale node_modules..." +rm -rf node_modules +rm -rf packages/core/dist/ +rm -rf packages/cli/node_modules/ +rm -rf packages/core/node_modules/ + +echo "review: Installing npm dependencies..." +npm install + +echo "--- build ---" +temp_dir_base="${TMPDIR:-/tmp}" +build_log_file="$(mktemp "${temp_dir_base}/npm_build_log.XXXXXX" || true)" +if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then + echo "Attempting to create temporary file in current directory as a fallback." >&2 + build_log_file="$(mktemp "./npm_build_log_fallback.XXXXXX" || true)" + if [[ -z "${build_log_file}" || ! -f "${build_log_file}" ]]; then + echo "ERROR: Critical - Failed to create any temporary build log file. Aborting." >&2 + exit 1 + fi +fi + +build_status=0 +build_command_to_run="FORCE_COLOR=1 CLICOLOR_FORCE=1 npm run build" + +echo "Running build. Output (with colors) will be shown below and saved to: ${build_log_file}" +echo "Build command: ${build_command_to_run}" + +if [[ "${uname_out}" == "Darwin" ]]; then + script -q "${build_log_file}" /bin/sh -c "${build_command_to_run}" || build_status=$? +else + if script -q -e -c "${build_command_to_run}" "${build_log_file}"; then + build_status=0 + else + build_status=$? + fi +fi + +if [[ "${build_status}" -ne 0 ]]; then + echo "ERROR: npm build failed with exit status ${build_status}." >&2 + echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2 + exit 1 +else + # shellcheck disable=SC2312 + if grep -q -i -E "\berror\b|\bfailed\b|ERR!|FATAL|critical" "${build_log_file}"; then + echo "ERROR: npm build completed with exit status 0, but suspicious error patterns were found in the build output." >&2 + echo "Review output above. Full log (with color codes) was in ${build_log_file}." >&2 + exit 1 + fi + echo "npm build completed successfully (exit status 0, no critical error patterns found in log)." + rm -f "${build_log_file}" +fi + +echo "-- running ---" +if ! npm start -- -m "${model}" -i="/review-frontend ${pr}"; then + echo "ERROR: npm start failed. Please check its output for details." >&2 + exit 1 +fi diff --git a/scripts/run_eval_regression.js b/scripts/run_eval_regression.js new file mode 100644 index 0000000000000000000000000000000000000000..7a64a6a2f9b1dab9bca9a334d258330abaa13dd2 --- /dev/null +++ b/scripts/run_eval_regression.js @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Orchestrates the PR evaluation process across multiple models. + * + * This script loops through a provided list of models, identifies trustworthy + * tests for each, executes the frugal regression check, and collects results + * into a single unified report. It exits with code 1 if any confirmed + * regressions are detected. + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; + +/** + * Main execution logic. + */ +async function main() { + const modelList = process.env.MODEL_LIST || 'gemini-3-flash-preview'; + const models = modelList.split(',').map((m) => m.trim()); + + let combinedReport = ''; + let hasRegression = false; + + console.log( + `๐Ÿš€ Starting evaluation orchestration for models: ${models.join(', ')}`, + ); + + for (const model of models) { + console.log(`\n--- Processing Model: ${model} ---`); + + try { + // 1. Identify Trustworthy Evals + console.log(`๐Ÿ” Identifying trustworthy tests for ${model}...`); + const output = execSync( + `node scripts/get_trustworthy_evals.js "${model}"`, + { + encoding: 'utf-8', + stdio: ['inherit', 'pipe', 'inherit'], // Capture stdout but pass stdin/stderr + }, + ).trim(); + + if (!output) { + console.log(`โ„น๏ธ No trustworthy tests found for ${model}. Skipping.`); + continue; + } + + // 2. Run Frugal Regression Check + console.log(`๐Ÿงช Running regression check for ${model}...`); + execSync(`node scripts/run_regression_check.js "${model}" "${output}"`, { + stdio: 'inherit', + }); + + // 3. Generate Report + console.log(`๐Ÿ“Š Generating report for ${model}...`); + const report = execSync(`node scripts/compare_evals.js "${model}"`, { + encoding: 'utf-8', + stdio: ['inherit', 'pipe', 'inherit'], + }).trim(); + + if (report) { + if (combinedReport) { + combinedReport += '\n\n---\n\n'; + } + combinedReport += report; + + // 4. Check for Regressions + // If the report contains the "Action Required" marker, it means a confirmed regression was found. + if (report.includes('Action Required')) { + hasRegression = true; + } + } + } catch (error) { + console.error(`โŒ Error processing model ${model}:`, error.message); + // We flag a failure if any model encountered a critical error + hasRegression = true; + } + } + + // Always save the combined report to a file so the workflow can capture it cleanly + if (combinedReport) { + fs.writeFileSync('eval_regression_report.md', combinedReport); + console.log( + '\n๐Ÿ“Š Final Markdown report saved to eval_regression_report.md', + ); + } + + // Log status for CI visibility, but don't exit with error + if (hasRegression) { + console.error( + '\nโš ๏ธ Confirmed regressions detected across one or more models. See PR comment for details.', + ); + } else { + console.log('\nโœ… All evaluations passed successfully (or were cleared).'); + } + + process.exit(0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/run_regression_check.js b/scripts/run_regression_check.js new file mode 100644 index 0000000000000000000000000000000000000000..1250671c30e9e7897c5045c31ac4ae039fab6b40 --- /dev/null +++ b/scripts/run_regression_check.js @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Executes a high-signal regression check for behavioral evaluations. + * + * This script runs a targeted set of stable tests in an optimistic first pass. + * If failures occur, it employs a "Best-of-4" retry logic to handle natural flakiness. + * For confirmed failures (0/3), it performs Dynamic Baseline Verification by + * checking the failure against the 'main' branch to distinguish between + * model drift and PR-introduced regressions. + */ + +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { quote } from 'shell-quote'; +import { escapeRegex } from './eval_utils.js'; + +/** + * Runs a set of tests using Vitest and returns the results. + */ +function runTests(files, pattern, model) { + const outputDir = path.resolve( + process.cwd(), + `evals/logs/pr-run-${Date.now()}`, + ); + fs.mkdirSync(outputDir, { recursive: true }); + + const filesToRun = files || 'evals/'; + console.log( + `๐Ÿš€ Running tests in ${filesToRun} with pattern: ${pattern?.slice(0, 100)}...`, + ); + + try { + const cmd = `npx vitest run --config evals/vitest.config.ts ${filesToRun} -t "${pattern}" --reporter=json --reporter=default --outputFile="${path.join(outputDir, 'report.json')}"`; + execSync(cmd, { + stdio: 'inherit', + env: { ...process.env, RUN_EVALS: '1', GEMINI_MODEL: model }, + }); + } catch { + // Vitest returns a non-zero exit code when tests fail. This is expected. + // We continue execution and handle the failures by parsing the JSON report. + } + + const reportPath = path.join(outputDir, 'report.json'); + return fs.existsSync(reportPath) + ? JSON.parse(fs.readFileSync(reportPath, 'utf-8')) + : null; +} + +/** + * Helper to find a specific assertion by name across all test files. + */ +function findAssertion(report, testName) { + if (!report?.testResults) return null; + for (const fileResult of report.testResults) { + const assertion = fileResult.assertionResults.find( + (a) => a.title === testName, + ); + if (assertion) return assertion; + } + return null; +} + +/** + * Parses command line arguments to identify model, files, and test pattern. + */ +function parseArgs() { + const modelArg = process.argv[2]; + const remainingArgs = process.argv.slice(3); + const fullArgsString = remainingArgs.join(' '); + const testPatternIndex = remainingArgs.indexOf('--test-pattern'); + + if (testPatternIndex !== -1) { + return { + model: modelArg, + files: remainingArgs.slice(0, testPatternIndex).join(' '), + pattern: remainingArgs.slice(testPatternIndex + 1).join(' '), + }; + } + + if (fullArgsString.includes('--test-pattern')) { + const parts = fullArgsString.split('--test-pattern'); + return { + model: modelArg, + files: parts[0].trim(), + pattern: parts[1].trim(), + }; + } + + // Fallback for manual mode: Pattern Model + const manualPattern = process.argv[2]; + const manualModel = process.argv[3]; + if (!manualModel) { + console.error('โŒ Error: No target model specified.'); + process.exit(1); + } + + let manualFiles = 'evals/'; + try { + const grepResult = execSync( + `grep -l ${quote([manualPattern])} evals/*.eval.ts`, + { encoding: 'utf-8' }, + ); + manualFiles = grepResult.split('\n').filter(Boolean).join(' '); + } catch { + // Grep returns exit code 1 if no files match the pattern. + // In this case, we fall back to scanning all files in the evals/ directory. + } + + return { + model: manualModel, + files: manualFiles, + pattern: manualPattern, + isManual: true, + }; +} + +/** + * Runs the targeted retry logic (Best-of-4) for a failing test. + */ +async function runRetries(testName, results, files, model) { + console.log(`\nRe-evaluating: ${testName}`); + + while ( + results[testName].passed < 2 && + results[testName].total - results[testName].passed < 3 && + results[testName].total < 4 + ) { + const attemptNum = results[testName].total + 1; + console.log(` Running attempt ${attemptNum}...`); + + const retry = runTests(files, escapeRegex(testName), model); + const retryAssertion = findAssertion(retry, testName); + + results[testName].total++; + if (retryAssertion?.status === 'passed') { + results[testName].passed++; + console.log( + ` โœ… Attempt ${attemptNum} passed. Score: ${results[testName].passed}/${results[testName].total}`, + ); + } else { + console.log( + ` โŒ Attempt ${attemptNum} failed (${retryAssertion?.status || 'unknown'}). Score: ${results[testName].passed}/${results[testName].total}`, + ); + } + + if (results[testName].passed >= 2) { + console.log( + ` โœ… Test cleared as Noisy Pass (${results[testName].passed}/${results[testName].total})`, + ); + } else if (results[testName].total - results[testName].passed >= 3) { + await verifyBaseline(testName, results, files, model); + } + } +} + +/** + * Verifies a potential regression against the 'main' branch. + */ +async function verifyBaseline(testName, results, files, model) { + console.log('\n--- Step 3: Dynamic Baseline Verification ---'); + console.log( + `โš ๏ธ Potential regression detected. Verifying baseline on 'main'...`, + ); + + try { + execSync('git stash push -m "eval-regression-check-stash"', { + stdio: 'inherit', + }); + const hasStash = execSync('git stash list') + .toString() + .includes('eval-regression-check-stash'); + execSync('git checkout main', { stdio: 'inherit' }); + + console.log( + `\n--- Running Baseline Verification on 'main' (Best-of-3) ---`, + ); + let baselinePasses = 0; + let baselineTotal = 0; + + while (baselinePasses === 0 && baselineTotal < 3) { + baselineTotal++; + console.log(` Baseline Attempt ${baselineTotal}...`); + const baselineRun = runTests(files, escapeRegex(testName), model); + if (findAssertion(baselineRun, testName)?.status === 'passed') { + baselinePasses++; + console.log(` โœ… Baseline Attempt ${baselineTotal} passed.`); + } else { + console.log(` โŒ Baseline Attempt ${baselineTotal} failed.`); + } + } + + execSync('git checkout -', { stdio: 'inherit' }); + if (hasStash) execSync('git stash pop', { stdio: 'inherit' }); + + if (baselinePasses === 0) { + console.log( + ` โ„น๏ธ Test also fails on 'main'. Marking as PRE-EXISTING (Cleared).`, + ); + results[testName].status = 'pre-existing'; + results[testName].passed = results[testName].total; // Clear for report + } else { + console.log( + ` โŒ Test passes on 'main' but fails in PR. Marking as CONFIRMED REGRESSION.`, + ); + results[testName].status = 'regression'; + } + } catch (error) { + console.error(` โŒ Failed to verify baseline: ${error.message}`); + + // Best-effort cleanup: try to return to the original branch. + try { + execSync('git checkout -', { stdio: 'ignore' }); + } catch { + // Ignore checkout errors during cleanup to avoid hiding the original error. + } + } +} + +/** + * Processes initial results and orchestrates retries/baseline checks. + */ +async function processResults(firstPass, pattern, model, files) { + if (!firstPass) return false; + + const results = {}; + const failingTests = []; + let totalProcessed = 0; + + for (const fileResult of firstPass.testResults) { + for (const assertion of fileResult.assertionResults) { + if (assertion.status !== 'passed' && assertion.status !== 'failed') { + continue; + } + + const name = assertion.title; + results[name] = { + passed: assertion.status === 'passed' ? 1 : 0, + total: 1, + file: fileResult.name, + }; + if (assertion.status === 'failed') failingTests.push(name); + totalProcessed++; + } + } + + if (totalProcessed === 0) { + console.error('โŒ Error: No matching tests were found or executed.'); + return false; + } + + if (failingTests.length === 0) { + console.log('โœ… All trustworthy tests passed on the first try!'); + } else { + console.log('\n--- Step 2: Best-of-4 Retries ---'); + console.log( + `โš ๏ธ ${failingTests.length} tests failed the optimistic run. Starting retries...`, + ); + for (const testName of failingTests) { + await runRetries(testName, results, files, model); + } + } + + saveResults(results); + return true; +} + +function saveResults(results) { + const finalReport = { timestamp: new Date().toISOString(), results }; + fs.writeFileSync( + 'evals/logs/pr_final_report.json', + JSON.stringify(finalReport, null, 2), + ); + console.log('\nFinal report saved to evals/logs/pr_final_report.json'); +} + +async function main() { + const { model, files, pattern, isManual } = parseArgs(); + + if (isManual) { + const firstPass = runTests(files, pattern, model); + const success = await processResults(firstPass, pattern, model, files); + process.exit(success ? 0 : 1); + } + + if (!pattern) { + console.log('No trustworthy tests to run.'); + process.exit(0); + } + + console.log('\n--- Step 1: Optimistic Run (N=1) ---'); + const firstPass = runTests(files, pattern, model); + const success = await processResults(firstPass, pattern, model, files); + process.exit(success ? 0 : 1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/seed-test-inbox.js b/scripts/seed-test-inbox.js new file mode 100644 index 0000000000000000000000000000000000000000..f3c735e1b9f8fef483d62e860d30224c74c6d642 --- /dev/null +++ b/scripts/seed-test-inbox.js @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Seeds the auto-memory inbox with REALISTIC patches for manual end-to-end + * testing of `/memory inbox`. Mirrors what one extraction-agent run would + * produce in practice: a single canonical `extraction.patch` per kind, + * containing multiple hunks (MEMORY.md update + sibling creation, etc.). + * + * Run AFTER `npm run build` from the project root: + * node scripts/seed-test-inbox.js + * + * The script will: + * 1. Initialize Storage for the current working directory. + * 2. Compute = ~/.gemini/tmp//memory/. + * 3. Seed `MEMORY.md` and TWO canonical inbox patches: + * - .inbox/private/extraction.patch (multi-hunk: update MEMORY.md + * + create verify-workflow.md + add MEMORY.md pointer to it) + * - .inbox/global/extraction.patch (creates ~/.gemini/GEMINI.md) + * 4. Print a verification checklist + the launch command. + * + * To clean up later, delete `/.inbox/` and the seeded + * MEMORY.md / GEMINI.md files. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '..'); + +const corePath = path.join(REPO_ROOT, 'packages/core/dist/src/index.js'); +try { + await fs.access(corePath); +} catch { + console.error( + `Cannot find built core at ${corePath}. Run \`npm run build\` first.`, + ); + process.exit(1); +} + +const { Storage } = await import(corePath); + +const cwd = process.cwd(); +const storage = new Storage(cwd); +await storage.initialize(); + +const memoryDir = storage.getProjectMemoryTempDir(); +const inboxPrivate = path.join(memoryDir, '.inbox', 'private'); +const inboxGlobal = path.join(memoryDir, '.inbox', 'global'); +const homeDir = os.homedir(); +const globalGeminiMd = path.join(homeDir, '.gemini', 'GEMINI.md'); + +console.log(`\n๐Ÿ”ง Seeding inbox for cwd: ${cwd}`); +console.log(` memoryDir = ${memoryDir}\n`); + +await fs.mkdir(inboxPrivate, { recursive: true }); +await fs.mkdir(inboxGlobal, { recursive: true }); + +const seeded = []; +async function seed(filePath, content, label) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf-8'); + seeded.push({ filePath, label }); +} + +// --- 1. Pre-existing private MEMORY.md so the update hunk has something to modify --- +const memoryMd = path.join(memoryDir, 'MEMORY.md'); +await seed( + memoryMd, + '# Project Memory\n\n- old fact about this project\n', + 'pre-existing active MEMORY.md', +); + +// --- 2. Canonical PRIVATE extraction.patch --- +// One file, multi-hunk: update MEMORY.md AND create verify-workflow.md +// AND add a pointer line for the sibling. This is what one extraction +// agent run typically produces. +const verifyWorkflowMd = path.join(memoryDir, 'verify-workflow.md'); +await fs.rm(verifyWorkflowMd, { force: true }); +await seed( + path.join(inboxPrivate, 'extraction.patch'), + [ + // Hunk 1: replace the existing fact and append a sibling pointer. + `--- ${memoryMd}`, + `+++ ${memoryMd}`, + `@@ -1,3 +1,4 @@`, + ` # Project Memory`, + ` `, + `-- old fact about this project`, + `+- new fact extracted from session analysis`, + `+- See ${verifyWorkflowMd} for the project's verification commands.`, + // Hunk 2: create the verify-workflow.md sibling. + `--- /dev/null`, + `+++ ${verifyWorkflowMd}`, + `@@ -0,0 +1,5 @@`, + `+# Verify Workflow`, + `+`, + `+- Run \`npm run typecheck\` after editing any *.ts file.`, + `+- Run \`npm run build --workspace @google/gemini-cli-core\` before testing CLI changes.`, + `+- Inbox patches are guarded by /memory inbox.`, + ``, + ].join('\n'), + 'canonical PRIVATE extraction.patch (2 hunks: MEMORY.md update + sibling create)', +); + +// --- 3. Canonical GLOBAL extraction.patch --- +// Creates ~/.gemini/GEMINI.md. Backs up any existing one first. +let existingGlobalGemini = null; +try { + existingGlobalGemini = await fs.readFile(globalGeminiMd, 'utf-8'); +} catch { + // Doesn't exist yet โ€” fine. +} +if (existingGlobalGemini !== null) { + const backupPath = `${globalGeminiMd}.seed-test-backup-${Date.now()}`; + await fs.copyFile(globalGeminiMd, backupPath); + console.log( + ` โ„น๏ธ Backed up existing ${globalGeminiMd} โ†’ ${backupPath}\n` + + ` (restore manually after testing if you wish.)\n`, + ); + await fs.rm(globalGeminiMd, { force: true }); +} +await seed( + path.join(inboxGlobal, 'extraction.patch'), + [ + `--- /dev/null`, + `+++ ${globalGeminiMd}`, + `@@ -0,0 +1,3 @@`, + `+# Global Personal Preferences`, + `+`, + `+- Prefer concise architecture summaries.`, + ``, + ].join('\n'), + 'canonical GLOBAL extraction.patch (creates ~/.gemini/GEMINI.md)', +); + +// --- Summary --- +console.log('Seeded files:'); +for (const { filePath, label } of seeded) { + console.log(` โœ“ ${path.relative(cwd, filePath)}`); + console.log(` ${label}\n`); +} + +console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); +console.log('NEXT STEPS'); +console.log('โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”'); +console.log(` +1. Enable autoMemory in your settings (the inbox command requires it): + + ~/.gemini/settings.json should contain: + { + "experimental": { "autoMemory": true } + } + + Or run this to set it: + node -e "const fs=require('fs'),p=require('os').homedir()+'/.gemini/settings.json';let s={};try{s=JSON.parse(fs.readFileSync(p,'utf-8'))}catch{}s.experimental=s.experimental||{};s.experimental.autoMemory=true;fs.mkdirSync(require('path').dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(s,null,2))" + +2. Launch the just-built CLI from THIS REPO ONLY. Do NOT use any globally + installed "gemini" binary โ€” it will be a stale build that doesn't know + about memory patches and will silently show only skills. + + npm run start + + (or, equivalently: node ${path.relative(cwd, REPO_ROOT)}/bundle/gemini.js) + + Sanity check before launching: + node ${path.relative(cwd, path.join(REPO_ROOT, 'scripts/check-inbox.js'))} + should report 2 memory patches (Private memory + Global memory). + +3. In the CLI, run: + + /memory inbox + + You should see exactly 2 entries in the "Memory Updates" group: + - Private memory 2 hunks from 1 source patch + - Global memory 1 hunk from 1 source patch + +4. Test focus preservation: arrow-down to "Global memory" โ†’ Enter โ†’ Esc โ†’ + cursor MUST still be on "Global memory" (not row 0). + +5. Open "Private memory" preview. You'll see TWO target sections (no + duplicates), since both hunks come from one source patch: + + ${memoryMd} + - new fact extracted from session analysis + - See ${verifyWorkflowMd} for the project's verification commands. + + ${verifyWorkflowMd} (new file) + # Verify Workflow + ... + +6. Apply each entry: + + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Item โ”‚ Action โ”‚ Expected outcome โ”‚ + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค + โ”‚ Private memory โ”‚ Apply โ”‚ "Applied all 1 private memory patch." โ”‚ + โ”‚ โ”‚ โ”‚ MEMORY.md updated; verify-workflow.md โ”‚ + โ”‚ โ”‚ โ”‚ created. โ”‚ + โ”‚ Global memory โ”‚ Apply โ”‚ "Applied all 1 global memory patch." โ”‚ + โ”‚ โ”‚ โ”‚ ~/.gemini/GEMINI.md created. โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +7. Verify final state on disk: + + cat ${path.relative(cwd, memoryMd)} # should show new fact + pointer line + cat ${path.relative(cwd, verifyWorkflowMd)} # should exist + cat ${globalGeminiMd} # should show "Prefer concise..." + ls ${path.relative(cwd, inboxPrivate)} # should be empty + ls ${path.relative(cwd, inboxGlobal)} # should be empty + +8. Cleanup: + + rm -rf ${path.relative(cwd, path.join(memoryDir, '.inbox'))} + rm -f ${path.relative(cwd, memoryMd)} + rm -f ${path.relative(cwd, verifyWorkflowMd)} + rm -f ${globalGeminiMd} +`); diff --git a/scripts/sync_project_dry_run.js b/scripts/sync_project_dry_run.js new file mode 100644 index 0000000000000000000000000000000000000000..6de12d3f9efaea2e7daeee18c65d4c224804edbc --- /dev/null +++ b/scripts/sync_project_dry_run.js @@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; + +const PROJECT_ID = 36; +const ORG = 'google-gemini'; +const REPO = 'google-gemini/gemini-cli'; +const MAINTAINERS_REPO = 'google-gemini/maintainers-gemini-cli'; + +// Parent issues to recursively traverse +const PARENT_ISSUES = [15374, 15456, 15324]; + +// Labels to Exclude +const EXCLUDED_LABELS = [ + 'help wanted', + 'status/need-triage', + 'status/need-info', + 'area/unknown', +]; + +// Labels that force inclusion (override exclusions) +const FORCE_INCLUDE_LABELS = ['๐Ÿ”’ maintainer only']; + +function runCommand(command) { + try { + return execSync(command, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + maxBuffer: 10 * 1024 * 1024, + }); + } catch { + return null; + } +} + +function getIssues(repo) { + console.log(`Fetching open issues from ${repo}...`); + const json = runCommand( + `gh issue list --repo ${repo} --state open --limit 3000 --json number,title,url,labels`, + ); + if (!json) { + return []; + } + return JSON.parse(json); +} + +function getIssueBody(repo, number) { + const json = runCommand( + `gh issue view ${number} --repo ${repo} --json body,title,url,number`, + ); + if (!json) { + return null; + } + return JSON.parse(json); +} + +function getProjectItems() { + console.log(`Fetching items from Project ${PROJECT_ID}...`); + const json = runCommand( + `gh project item-list ${PROJECT_ID} --owner ${ORG} --format json --limit 3000`, + ); + if (!json) { + return []; + } + return JSON.parse(json).items; +} + +function shouldInclude(issue) { + const labels = issue.labels.map((l) => l.name); + + // Check Force Include first + if (labels.some((l) => FORCE_INCLUDE_LABELS.includes(l))) { + return true; + } + + // Check Exclude + if (labels.some((l) => EXCLUDED_LABELS.includes(l))) { + return false; + } + + return true; +} + +// Recursive function to find children +const visitedParents = new Set(); +async function findChildren(repo, number, depth = 0) { + const key = `${repo}/${number}`; + if (visitedParents.has(key) || depth > 3) { + return []; // Avoid cycles and too deep + } + visitedParents.add(key); + + process.stdout.write('.'); // progress indicator + const issue = getIssueBody(repo, number); + if (!issue) { + return []; + } + + const children = []; + const body = issue.body || ''; + + // Regex to find #1234 (local repo) and https://github.com/.../issues/1234 (cross repo) + // 1. Local references: #1234 + const localMatches = [ + ...body.matchAll(/(? i.content.url)); + const toAddMap = new Map(); + const allowedUrls = new Set(); // URLs that are safe to stay/be added + + // 1. Label Logic + for (const issue of issues) { + if (shouldInclude(issue)) { + allowedUrls.add(issue.url); + if (!currentUrlMap.has(issue.url)) { + toAddMap.set(issue.url, issue); + } + } + } + + // 2. Hierarchy Logic + console.log('\n--- SCANNING HIERARCHY ---'); + console.log(`Fetching recursive children of: ${PARENT_ISSUES.join(', ')}`); + for (const parentId of PARENT_ISSUES) { + const descendants = await findChildren(REPO, parentId); + for (const item of descendants) { + if (item.repo === REPO || item.repo === MAINTAINERS_REPO) { + allowedUrls.add(item.url); // Mark as allowed + if (!currentUrlMap.has(item.url) && !toAddMap.has(item.url)) { + toAddMap.set(item.url, item); + } + } + } + } + console.log('\nScanning complete.'); + + // 3. Removal Logic + const toRemove = []; + for (const item of currentItems) { + // Protect Maintainers Repo + if ( + item.content.repository === MAINTAINERS_REPO || + (item.content.url && item.content.url.includes('maintainers-gemini-cli')) + ) { + continue; + } + + // If not allowed by Labels OR Hierarchy, remove + if (!allowedUrls.has(item.content.url)) { + toRemove.push(item); + } + } + + const toAdd = Array.from(toAddMap.values()); + + console.log('\n--- ANALYSIS ---'); + console.log(`Items to ADD: ${toAdd.length}`); + console.log(`Items to REMOVE: ${toRemove.length}`); + + if (toAdd.length > 0) { + console.log('\n--- EXAMPLES TO ADD ---'); + toAdd + .slice(0, 5) + .forEach((i) => console.log(`[+] #${i.number} ${i.title}`)); + } + + if (toRemove.length > 0) { + console.log('\n--- EXAMPLES TO REMOVE ---'); + toRemove + .slice(0, 5) + .forEach((i) => console.log(`[-] ${i.content.title} (${i.status})`)); + } + + if (process.argv.includes('--execute')) { + console.log('\n--- EXECUTING CHANGES ---'); + + for (const issue of toAdd) { + process.stdout.write(`Adding ${issue.url}... `); + const res = runCommand( + `gh project item-add ${PROJECT_ID} --owner ${ORG} --url "${issue.url}" --format json`, + ); + process.stdout.write(res ? 'OK\n' : 'FAILED\n'); + } + + for (const item of toRemove) { + process.stdout.write(`Removing ${item.id}... `); + const res = runCommand( + `gh project item-delete ${PROJECT_ID} --owner ${ORG} --id ${item.id}`, + ); + process.stdout.write(res ? 'OK\n' : 'FAILED\n'); + } + console.log('Done.'); + } else { + console.log('\nRun with --execute to apply.'); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/telemetry_gcp.js b/scripts/telemetry_gcp.js new file mode 100644 index 0000000000000000000000000000000000000000..f60a05ad1b244ccb7b37dcae57f16191f730dba8 --- /dev/null +++ b/scripts/telemetry_gcp.js @@ -0,0 +1,190 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import * as fs from 'node:fs'; +import { spawn, execSync } from 'node:child_process'; +import { + OTEL_DIR, + BIN_DIR, + fileExists, + waitForPort, + ensureBinary, + manageTelemetrySettings, + registerCleanup, +} from './telemetry_utils.js'; + +const OTEL_CONFIG_FILE = path.join(OTEL_DIR, 'collector-gcp.yaml'); +const OTEL_LOG_FILE = path.join(OTEL_DIR, 'collector-gcp.log'); + +const getOtelConfigContent = (projectId) => ` +receivers: + otlp: + protocols: + grpc: + endpoint: "localhost:4317" +processors: + batch: + timeout: 1s +exporters: + googlecloud: + project: "${projectId}" + metric: + prefix: "custom.googleapis.com/gemini_cli" + log: + default_log_name: "gemini_cli" + debug: + verbosity: detailed +service: + telemetry: + logs: + level: "debug" + metrics: + level: "none" + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [googlecloud] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [googlecloud, debug] + logs: + receivers: [otlp] + processors: [batch] + exporters: [googlecloud, debug] +`; + +async function main() { + console.log('โœจ Starting Local Telemetry Exporter for Google Cloud โœจ'); + + let collectorProcess; + let collectorLogFd; + + const originalSandboxSetting = manageTelemetrySettings( + true, + 'http://localhost:4317', + 'gcp', + ); + registerCleanup( + () => [collectorProcess].filter((p) => p), // Function to get processes + () => [collectorLogFd].filter((fd) => fd), // Function to get FDs + originalSandboxSetting, + ); + + const projectId = process.env.OTLP_GOOGLE_CLOUD_PROJECT; + if (!projectId) { + console.error( + '๐Ÿ›‘ Error: OTLP_GOOGLE_CLOUD_PROJECT environment variable is not exported.', + ); + console.log( + ' Please set it to your Google Cloud Project ID and try again.', + ); + console.log(' `export OTLP_GOOGLE_CLOUD_PROJECT=your-project-id`'); + process.exit(1); + } + console.log(`โœ… Using OTLP Google Cloud Project ID: ${projectId}`); + + console.log('\n๐Ÿ”‘ Please ensure you are authenticated with Google Cloud:'); + console.log( + ' - Run `gcloud auth application-default login` OR ensure `GOOGLE_APPLICATION_CREDENTIALS` environment variable points to a valid service account key.', + ); + console.log( + ' - The account needs "Cloud Trace Agent", "Monitoring Metric Writer", and "Logs Writer" roles.', + ); + + if (!fileExists(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true }); + + const otelcolPath = await ensureBinary( + 'otelcol-contrib', + 'open-telemetry/opentelemetry-collector-releases', + (version, platform, arch, ext) => + `otelcol-contrib_${version}_${platform}_${arch}.${ext}`, + 'otelcol-contrib', + false, // isJaeger = false + ).catch((e) => { + console.error(`๐Ÿ›‘ Error getting otelcol-contrib: ${e.message}`); + return null; + }); + if (!otelcolPath) process.exit(1); + + console.log('๐Ÿงน Cleaning up old processes and logs...'); + try { + execSync('pkill -f "otelcol-contrib"'); + console.log('โœ… Stopped existing otelcol-contrib process.'); + } catch { + /* no-op */ + } + try { + fs.unlinkSync(OTEL_LOG_FILE); + console.log('โœ… Deleted old GCP collector log.'); + } catch (e) { + if (e.code !== 'ENOENT') console.error(e); + } + + if (!fileExists(OTEL_DIR)) fs.mkdirSync(OTEL_DIR, { recursive: true }); + fs.writeFileSync(OTEL_CONFIG_FILE, getOtelConfigContent(projectId)); + console.log(`๐Ÿ“„ Wrote OTEL collector config to ${OTEL_CONFIG_FILE}`); + + const spawnEnv = { ...process.env }; + + console.log(`๐Ÿš€ Starting OTEL collector for GCP... Logs: ${OTEL_LOG_FILE}`); + collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a'); + collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], { + stdio: ['ignore', collectorLogFd, collectorLogFd], + env: spawnEnv, + }); + + console.log( + `โณ Waiting for OTEL collector to start (PID: ${collectorProcess.pid})...`, + ); + + try { + await waitForPort(4317); + console.log(`โœ… OTEL collector started successfully on port 4317.`); + } catch (err) { + console.error(`๐Ÿ›‘ Error: OTEL collector failed to start on port 4317.`); + console.error(err.message); + if (collectorProcess && collectorProcess.pid) { + process.kill(collectorProcess.pid, 'SIGKILL'); + } + if (fileExists(OTEL_LOG_FILE)) { + console.error('๐Ÿ“„ OTEL Collector Log Output:'); + console.error(fs.readFileSync(OTEL_LOG_FILE, 'utf-8')); + } + process.exit(1); + } + + collectorProcess.on('error', (err) => { + console.error(`${collectorProcess.spawnargs[0]} process error:`, err); + process.exit(1); + }); + + console.log(`\nโœจ Local OTEL collector for GCP is running.`); + console.log( + '\n๐Ÿš€ To send telemetry, run the Gemini CLI in a separate terminal window.', + ); + console.log(`\n๐Ÿ“„ Collector logs are being written to: ${OTEL_LOG_FILE}`); + console.log( + `๐Ÿ“„ Tail collector logs in another terminal: tail -f ${OTEL_LOG_FILE}`, + ); + console.log(`\n๐Ÿ“Š View your telemetry data in Google Cloud Console:`); + console.log( + ` - Logs: https://console.cloud.google.com/logs/query;query=logName%3D%22projects%2F${projectId}%2Flogs%2Fgemini_cli%22?project=${projectId}`, + ); + console.log( + ` - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer?project=${projectId}`, + ); + console.log( + ` - Traces: https://console.cloud.google.com/traces/list?project=${projectId}`, + ); + console.log(`\nPress Ctrl+C to exit.`); +} + +main(); diff --git a/scripts/telemetry_utils.js b/scripts/telemetry_utils.js new file mode 100644 index 0000000000000000000000000000000000000000..2abda4f49b6aecc104466b459a3c8a7c5eb4cfe4 --- /dev/null +++ b/scripts/telemetry_utils.js @@ -0,0 +1,456 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import crypto from 'node:crypto'; +import { GEMINI_DIR } from '@google/gemini-cli-core'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const projectRoot = path.resolve(__dirname, '..'); +const projectHash = crypto + .createHash('sha256') + .update(projectRoot) + .digest('hex'); + +// Returns the home directory, respecting GEMINI_CLI_HOME +const homedir = () => process.env['GEMINI_CLI_HOME'] || os.homedir(); + +// User-level .gemini directory in home +const USER_GEMINI_DIR = path.join(homedir(), GEMINI_DIR); +// Project-level .gemini directory in the workspace +const WORKSPACE_GEMINI_DIR = path.join(projectRoot, GEMINI_DIR); + +// Telemetry artifacts are stored in a hashed directory under the user's ~/.gemini/tmp +export const OTEL_DIR = path.join(USER_GEMINI_DIR, 'tmp', projectHash, 'otel'); +export const BIN_DIR = path.join(OTEL_DIR, 'bin'); + +// Workspace settings remain in the project's .gemini directory +export const WORKSPACE_SETTINGS_FILE = path.join( + WORKSPACE_GEMINI_DIR, + 'settings.json', +); + +export function getJson(url) { + const tmpFile = path.join( + os.tmpdir(), + `gemini-cli-releases-${Date.now()}.json`, + ); + try { + const result = spawnSync( + 'curl', + ['-sL', '-H', 'User-Agent: gemini-cli-dev-script', '-o', tmpFile, url], + { stdio: 'pipe', encoding: 'utf-8' }, + ); + if (result.status !== 0) { + throw new Error(result.stderr); + } + const content = fs.readFileSync(tmpFile, 'utf-8'); + return JSON.parse(content); + } catch (e) { + console.error(`Failed to fetch or parse JSON from ${url}`); + throw e; + } finally { + if (fs.existsSync(tmpFile)) { + fs.unlinkSync(tmpFile); + } + } +} + +export function downloadFile(url, dest) { + try { + const result = spawnSync('curl', ['-fL', '-sS', '-o', dest, url], { + stdio: 'pipe', + encoding: 'utf-8', + }); + if (result.status !== 0) { + throw new Error(result.stderr); + } + return dest; + } catch (e) { + console.error(`Failed to download file from ${url}`); + throw e; + } +} + +export function findFile(startPath, filter) { + if (!fs.existsSync(startPath)) { + return null; + } + const files = fs.readdirSync(startPath); + for (const file of files) { + const filename = path.join(startPath, file); + const stat = fs.lstatSync(filename); + if (stat.isDirectory()) { + const result = findFile(filename, filter); + if (result) return result; + } else if (filter(file)) { + return filename; + } + } + return null; +} + +export function fileExists(filePath) { + return fs.existsSync(filePath); +} + +export function readJsonFile(filePath) { + if (!fileExists(filePath)) { + return {}; + } + const content = fs.readFileSync(filePath, 'utf-8'); + try { + return JSON.parse(content); + } catch (e) { + console.error(`Error parsing JSON from ${filePath}: ${e.message}`); + return {}; + } +} + +export function writeJsonFile(filePath, data) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); +} + +export function moveBinary(source, destination) { + try { + fs.renameSync(source, destination); + } catch (error) { + if (error.code !== 'EXDEV') { + throw error; + } + // Handle a cross-device error: copy-to-temp-then-rename. + const destDir = path.dirname(destination); + const destFile = path.basename(destination); + const tempDest = path.join(destDir, `${destFile}.tmp`); + + try { + fs.copyFileSync(source, tempDest); + fs.renameSync(tempDest, destination); + } catch (moveError) { + // If copy or rename fails, clean up the intermediate temp file. + if (fs.existsSync(tempDest)) { + fs.unlinkSync(tempDest); + } + throw moveError; + } + fs.unlinkSync(source); + } +} + +export function waitForPort(port, timeout = 10000) { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + const tryConnect = () => { + const socket = new net.Socket(); + socket.once('connect', () => { + socket.end(); + resolve(); + }); + socket.once('error', (_) => { + if (Date.now() - startTime > timeout) { + reject(new Error(`Timeout waiting for port ${port} to open.`)); + } else { + setTimeout(tryConnect, 500); + } + }); + socket.connect(port, 'localhost'); + }; + tryConnect(); + }); +} + +export async function ensureBinary( + executableName, + repo, + assetNameCallback, + binaryNameInArchive, + isJaeger = false, +) { + const executablePath = path.join(BIN_DIR, executableName); + if (fileExists(executablePath)) { + console.log(`โœ… ${executableName} already exists at ${executablePath}`); + return executablePath; + } + + console.log(`๐Ÿ” ${executableName} not found. Downloading from ${repo}...`); + + const platform = process.platform === 'win32' ? 'windows' : process.platform; + const arch = process.arch === 'x64' ? 'amd64' : process.arch; + const ext = platform === 'windows' ? 'zip' : 'tar.gz'; + + if (isJaeger && platform === 'windows' && arch === 'arm64') { + console.warn( + `โš ๏ธ Jaeger does not have a release for Windows on ARM64. Skipping.`, + ); + return null; + } + + let release; + let asset; + + if (isJaeger) { + console.log(`๐Ÿ” Finding latest Jaeger v2+ asset...`); + const releases = getJson(`https://api.github.com/repos/${repo}/releases`); + const sortedReleases = releases + .filter((r) => !r.prerelease && r.tag_name.startsWith('v')) + .sort((a, b) => { + const aVersion = a.tag_name.substring(1).split('.').map(Number); + const bVersion = b.tag_name.substring(1).split('.').map(Number); + for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) { + if ((aVersion[i] || 0) > (bVersion[i] || 0)) return -1; + if ((aVersion[i] || 0) < (bVersion[i] || 0)) return 1; + } + return 0; + }); + + for (const r of sortedReleases) { + const expectedSuffix = + platform === 'windows' + ? `-${platform}-${arch}.zip` + : `-${platform}-${arch}.tar.gz`; + const foundAsset = r.assets.find( + (a) => + a.name.startsWith('jaeger-2.') && a.name.endsWith(expectedSuffix), + ); + + if (foundAsset) { + release = r; + asset = foundAsset; + console.log( + `โฌ‡๏ธ Found ${asset.name} in release ${r.tag_name}, downloading...`, + ); + break; + } + } + if (!asset) { + throw new Error( + `Could not find a suitable Jaeger v2 asset for platform ${platform}/${arch}.`, + ); + } + } else { + release = getJson(`https://api.github.com/repos/${repo}/releases/latest`); + const version = release.tag_name.startsWith('v') + ? release.tag_name.substring(1) + : release.tag_name; + const assetName = assetNameCallback(version, platform, arch, ext); + asset = release.assets.find((a) => a.name === assetName); + if (!asset) { + throw new Error( + `Could not find a suitable asset for ${repo} (version ${version}) on platform ${platform}/${arch}. Searched for: ${assetName}`, + ); + } + } + + const downloadUrl = asset.browser_download_url; + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-telemetry-'), + ); + const archivePath = path.join(tmpDir, asset.name); + + try { + console.log(`โฌ‡๏ธ Downloading ${asset.name}...`); + downloadFile(downloadUrl, archivePath); + console.log(`๐Ÿ“ฆ Extracting ${asset.name}...`); + + const actualExt = asset.name.endsWith('.zip') ? 'zip' : 'tar.gz'; + + let result; + if (actualExt === 'zip') { + result = spawnSync('unzip', ['-o', archivePath, '-d', tmpDir], { + stdio: 'pipe', + encoding: 'utf-8', + }); + } else { + result = spawnSync('tar', ['-xzf', archivePath, '-C', tmpDir], { + stdio: 'pipe', + encoding: 'utf-8', + }); + } + if (result.status !== 0) { + throw new Error(result.stderr); + } + + const nameToFind = binaryNameInArchive || executableName; + const foundBinaryPath = findFile(tmpDir, (file) => { + if (platform === 'windows') { + return file === `${nameToFind}.exe`; + } + return file === nameToFind; + }); + + if (!foundBinaryPath) { + throw new Error( + `Could not find binary "${nameToFind}" in extracted archive at ${tmpDir}. Contents: ${fs.readdirSync(tmpDir).join(', ')}`, + ); + } + + moveBinary(foundBinaryPath, executablePath); + + if (platform !== 'windows') { + fs.chmodSync(executablePath, '755'); + } + + console.log(`โœ… ${executableName} installed at ${executablePath}`); + return executablePath; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + if (fs.existsSync(archivePath)) { + fs.unlinkSync(archivePath); + } + } +} + +export function manageTelemetrySettings( + enable, + oTelEndpoint = 'http://localhost:4317', + target = 'local', + originalSandboxSettingToRestore, + otlpProtocol = 'grpc', +) { + const workspaceSettings = readJsonFile(WORKSPACE_SETTINGS_FILE); + const currentSandboxSetting = workspaceSettings.sandbox; + let settingsModified = false; + + if (typeof workspaceSettings.telemetry !== 'object') { + workspaceSettings.telemetry = {}; + } + + if (enable) { + if (workspaceSettings.telemetry.enabled !== true) { + workspaceSettings.telemetry.enabled = true; + settingsModified = true; + console.log('โš™๏ธ Enabled telemetry in workspace settings.'); + } + if (workspaceSettings.sandbox !== false) { + workspaceSettings.sandbox = false; + settingsModified = true; + console.log('โœ… Disabled sandbox mode for telemetry.'); + } + if (workspaceSettings.telemetry.otlpEndpoint !== oTelEndpoint) { + workspaceSettings.telemetry.otlpEndpoint = oTelEndpoint; + settingsModified = true; + console.log(`๐Ÿ”ง Set telemetry OTLP endpoint to ${oTelEndpoint}.`); + } + if (workspaceSettings.telemetry.target !== target) { + workspaceSettings.telemetry.target = target; + settingsModified = true; + console.log(`๐ŸŽฏ Set telemetry target to ${target}.`); + } + if (workspaceSettings.telemetry.otlpProtocol !== otlpProtocol) { + workspaceSettings.telemetry.otlpProtocol = otlpProtocol; + settingsModified = true; + console.log(`๐Ÿ”ง Set telemetry OTLP protocol to ${otlpProtocol}.`); + } + } else { + if (workspaceSettings.telemetry.enabled === true) { + delete workspaceSettings.telemetry.enabled; + settingsModified = true; + console.log('โš™๏ธ Disabled telemetry in workspace settings.'); + } + if (workspaceSettings.telemetry.otlpEndpoint) { + delete workspaceSettings.telemetry.otlpEndpoint; + settingsModified = true; + console.log('๐Ÿ”ง Cleared telemetry OTLP endpoint.'); + } + if (workspaceSettings.telemetry.target) { + delete workspaceSettings.telemetry.target; + settingsModified = true; + console.log('๐ŸŽฏ Cleared telemetry target.'); + } + if (workspaceSettings.telemetry.otlpProtocol) { + delete workspaceSettings.telemetry.otlpProtocol; + settingsModified = true; + console.log('๐Ÿ”ง Cleared telemetry OTLP protocol.'); + } + if (Object.keys(workspaceSettings.telemetry).length === 0) { + delete workspaceSettings.telemetry; + } + + if ( + originalSandboxSettingToRestore !== undefined && + workspaceSettings.sandbox !== originalSandboxSettingToRestore + ) { + workspaceSettings.sandbox = originalSandboxSettingToRestore; + settingsModified = true; + console.log('โœ… Restored original sandbox setting.'); + } + } + + if (settingsModified) { + writeJsonFile(WORKSPACE_SETTINGS_FILE, workspaceSettings); + console.log('โœ… Workspace settings updated.'); + } else { + console.log( + enable + ? 'โœ… Workspace settings are already configured for telemetry.' + : 'โœ… Workspace settings already reflect telemetry disabled.', + ); + } + return currentSandboxSetting; +} + +export function registerCleanup( + getProcesses, + getLogFileDescriptors, + originalSandboxSetting, +) { + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + + console.log('\n๐Ÿ‘‹ Shutting down...'); + + manageTelemetrySettings(false, null, null, originalSandboxSetting); + + const processes = getProcesses ? getProcesses() : []; + processes.forEach((proc) => { + if (proc && proc.pid) { + const name = path.basename(proc.spawnfile); + try { + console.log(`๐Ÿ›‘ Stopping ${name} (PID: ${proc.pid})...`); + process.kill(proc.pid, 'SIGTERM'); + console.log(`โœ… ${name} stopped.`); + } catch (e) { + if (e.code !== 'ESRCH') { + console.error(`Error stopping ${name}: ${e.message}`); + } + } + } + }); + + const logFileDescriptors = getLogFileDescriptors + ? getLogFileDescriptors() + : []; + logFileDescriptors.forEach((fd) => { + if (fd) { + try { + fs.closeSync(fd); + } catch { + /* no-op */ + } + } + }); + }; + + process.on('exit', cleanup); + process.on('SIGINT', () => process.exit(0)); + process.on('SIGTERM', () => process.exit(0)); + process.on('uncaughtException', (err) => { + console.error('Uncaught Exception:', err); + cleanup(); + process.exit(1); + }); +} diff --git a/scripts/test-windows-paths.js b/scripts/test-windows-paths.js new file mode 100644 index 0000000000000000000000000000000000000000..eebdddd7698ec4db9eadf5192f9b7a84d7794c49 --- /dev/null +++ b/scripts/test-windows-paths.js @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Test how paths are normalized +function testPathNormalization() { + // Use platform-agnostic path construction instead of hardcoded paths + const testPath = path.join('test', 'project', 'src', 'file.md'); + const absoluteTestPath = path.resolve('test', 'project', 'src', 'file.md'); + + console.log('Testing path normalization:'); + console.log('Relative path:', testPath); + console.log('Absolute path:', absoluteTestPath); + + // Test path.join with different segments + const joinedPath = path.join('test', 'project', 'src', 'file.md'); + console.log('Joined path:', joinedPath); + + // Test path.normalize + console.log('Normalized relative path:', path.normalize(testPath)); + console.log('Normalized absolute path:', path.normalize(absoluteTestPath)); + + // Test how the test would see these paths + const testContent = `--- File: ${absoluteTestPath} ---\nContent\n--- End of File: ${absoluteTestPath} ---`; + console.log('\nTest content with platform-agnostic paths:'); + console.log(testContent); + + // Try to match with different patterns + const marker = `--- File: ${absoluteTestPath} ---`; + console.log('\nTrying to match:', marker); + console.log('Direct match:', testContent.includes(marker)); + + // Test with normalized path in marker + const normalizedMarker = `--- File: ${path.normalize(absoluteTestPath)} ---`; + console.log( + 'Normalized marker match:', + testContent.includes(normalizedMarker), + ); + + // Test path resolution + const __filename = fileURLToPath(import.meta.url); + console.log('\nCurrent file path:', __filename); + console.log('Directory name:', path.dirname(__filename)); +} + +testPathNormalization(); diff --git a/scripts/version.js b/scripts/version.js new file mode 100644 index 0000000000000000000000000000000000000000..144d4a8212dccfa620223dce02dcbca947644ab0 --- /dev/null +++ b/scripts/version.js @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// A script to handle versioning and ensure all related changes are in a single, atomic commit. + +function run(command) { + console.log(`> ${command}`); + execSync(command, { stdio: 'inherit' }); +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +function writeJson(filePath, data) { + writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n'); +} + +// 1. Get the version type from the command line arguments. +const versionType = process.argv[2]; +if (!versionType) { + console.error('Error: No version type specified.'); + console.error('Usage: npm run version '); + process.exit(1); +} + +// 2. Bump the version in the root and all workspace package.json files. +run(`npm version ${versionType} --no-git-tag-version --allow-same-version`); + +// 3. Get all workspaces and filter out the one we don't want to version. +const workspacesToExclude = []; +let lsOutput; +try { + lsOutput = JSON.parse( + execSync('npm ls --workspaces --json --depth=0').toString(), + ); +} catch (e) { + // `npm ls` can exit with a non-zero status code if there are issues + // with dependencies, but it will still produce the JSON output we need. + // We'll try to parse the stdout from the error object. + if (e.stdout) { + console.warn( + 'Warning: `npm ls` exited with a non-zero status code. Attempting to proceed with the output.', + ); + try { + lsOutput = JSON.parse(e.stdout.toString()); + } catch (parseError) { + console.error( + 'Error: Failed to parse JSON from `npm ls` output even after `npm ls` failed.', + ); + console.error('npm ls stderr:', e.stderr.toString()); + console.error('Parse error:', parseError); + process.exit(1); + } + } else { + console.error('Error: `npm ls` failed with no output.'); + console.error(e.stderr?.toString() || e); + process.exit(1); + } +} +const allWorkspaces = Object.keys(lsOutput.dependencies || {}); +const workspacesToVersion = allWorkspaces.filter( + (wsName) => !workspacesToExclude.includes(wsName), +); + +for (const workspaceName of workspacesToVersion) { + run( + `npm version ${versionType} --workspace ${workspaceName} --no-git-tag-version --allow-same-version`, + ); +} + +// 4. Get the new version number from the root package.json +const rootPackageJsonPath = resolve(process.cwd(), 'package.json'); +const newVersion = readJson(rootPackageJsonPath).version; + +// 4. Update the sandboxImageUri in the root package.json +const rootPackageJson = readJson(rootPackageJsonPath); +if (rootPackageJson.config?.sandboxImageUri) { + rootPackageJson.config.sandboxImageUri = + rootPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`); + console.log(`Updated sandboxImageUri in root to use version ${newVersion}`); + writeJson(rootPackageJsonPath, rootPackageJson); +} + +// 5. Update the sandboxImageUri in the cli package.json +const cliPackageJsonPath = resolve(process.cwd(), 'packages/cli/package.json'); +const cliPackageJson = readJson(cliPackageJsonPath); +if (cliPackageJson.config?.sandboxImageUri) { + cliPackageJson.config.sandboxImageUri = + cliPackageJson.config.sandboxImageUri.replace(/:.*$/, `:${newVersion}`); + console.log( + `Updated sandboxImageUri in cli package to use version ${newVersion}`, + ); + writeJson(cliPackageJsonPath, cliPackageJson); +} + +// 6. Run `npm install` to update package-lock.json. +run( + 'npm install --workspace packages/cli --workspace packages/core --package-lock-only', +); + +console.log(`Successfully bumped versions to v${newVersion}.`); diff --git a/sea/sea-launch.cjs b/sea/sea-launch.cjs new file mode 100644 index 0000000000000000000000000000000000000000..4fb45bfb7a12073b77ba124db1e72555c77f9bd6 --- /dev/null +++ b/sea/sea-launch.cjs @@ -0,0 +1,278 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +const { getAsset } = require('node:sea'); +const process = require('node:process'); +const nodeModule = require('node:module'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const fs = require('node:fs'); +const os = require('node:os'); +const crypto = require('node:crypto'); + +// --- Helper Functions --- + +/** + * Strips the "ghost" argument that Node SEA sometimes injects (argv[2] == argv[0]). + * @param {string[]} argv + * @param {string} execPath + * @param {function} resolveFn + * @returns {boolean} True if an argument was removed. + */ +function sanitizeArgv(argv, execPath, resolveFn = path.resolve) { + if (argv.length > 2) { + const binaryAbs = execPath; + const arg2Abs = resolveFn(argv[2]); + if (binaryAbs === arg2Abs) { + argv.splice(2, 1); + return true; + } + } + return false; +} + +/** + * Sanitizes a string for use in file paths. + * @param {string} name + * @returns {string} + */ +function getSafeName(name) { + return (name || 'unknown').toString().replace(/[^a-zA-Z0-9.-]/g, '_'); +} + +/** + * Verifies the integrity of the runtime directory against the manifest. + * @param {string} dir + * @param {object} manifest + * @param {object} fsMod + * @param {object} cryptoMod + * @returns {boolean} + */ +function verifyIntegrity(dir, manifest, fsMod = fs, cryptoMod = crypto) { + try { + const calculateHash = (filePath) => { + const hash = cryptoMod.createHash('sha256'); + const fd = fsMod.openSync(filePath, 'r'); + const buffer = new Uint8Array(65536); // 64KB + try { + let bytesRead = 0; + while ( + (bytesRead = fsMod.readSync(fd, buffer, 0, buffer.length, null)) !== 0 + ) { + hash.update(buffer.subarray(0, bytesRead)); + } + } finally { + fsMod.closeSync(fd); + } + return hash.digest('hex'); + }; + + if (calculateHash(path.join(dir, 'gemini.mjs')) !== manifest.mainHash) + return false; + if (manifest.files) { + for (const file of manifest.files) { + if (calculateHash(path.join(dir, file.path)) !== file.hash) + return false; + } + } + return true; + } catch { + return false; + } +} + +/** + * Prepares the runtime directory, extracting assets if necessary. + * @param {object} manifest + * @param {function} getAssetFn + * @param {object} deps Dependencies (fs, os, path, processEnv) + * @returns {string} The path to the prepared runtime directory. + */ +function prepareRuntime(manifest, getAssetFn, deps = {}) { + const fsMod = deps.fs || fs; + const osMod = deps.os || os; + const pathMod = deps.path || path; + const processEnv = deps.processEnv || process.env; + const processPid = deps.processPid || process.pid; + const processUid = + deps.processUid || (process.getuid ? process.getuid() : 'unknown'); + + const version = manifest.version || '0.0.0'; + const safeVersion = getSafeName(version); + const userInfo = osMod.userInfo(); + const username = + userInfo.username || processEnv.USER || processUid || 'unknown'; + const safeUsername = getSafeName(username); + + let tempBase = osMod.tmpdir(); + + if (process.platform === 'win32' && processEnv.LOCALAPPDATA) { + const appDir = pathMod.join(processEnv.LOCALAPPDATA, 'Google', 'GeminiCLI'); + try { + if (!fsMod.existsSync(appDir)) { + fsMod.mkdirSync(appDir, { recursive: true, mode: 0o700 }); + } + tempBase = appDir; + } catch { + // Fallback to tmpdir + } + } + + const finalRuntimeDir = pathMod.join( + tempBase, + `gemini-runtime-${safeVersion}-${safeUsername}`, + ); + + let runtimeDir; + let useExisting = false; + + const isSecure = (dir) => { + try { + const stat = fsMod.lstatSync(dir); + if (!stat.isDirectory()) return false; + if (processUid !== 'unknown' && stat.uid !== processUid) return false; + // Skip strict permission check on Windows as it's unreliable with standard fs.stat + if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o700) + return false; + return true; + } catch { + return false; + } + }; + if (fsMod.existsSync(finalRuntimeDir)) { + if (isSecure(finalRuntimeDir)) { + if ( + verifyIntegrity(finalRuntimeDir, manifest, fsMod, deps.crypto || crypto) + ) { + runtimeDir = finalRuntimeDir; + useExisting = true; + } else { + try { + fsMod.rmSync(finalRuntimeDir, { recursive: true, force: true }); + } catch {} + } + } else { + try { + fsMod.rmSync(finalRuntimeDir, { recursive: true, force: true }); + } catch {} + } + } + + if (!useExisting) { + const setupDir = pathMod.join( + tempBase, + `gemini-setup-${processPid}-${Date.now()}`, + ); + + try { + fsMod.mkdirSync(setupDir, { recursive: true, mode: 0o700 }); + const writeToSetup = (assetKey, relPath) => { + const content = getAssetFn(assetKey); + if (!content) return; + const destPath = pathMod.join(setupDir, relPath); + const destDir = pathMod.dirname(destPath); + if (!fsMod.existsSync(destDir)) + fsMod.mkdirSync(destDir, { recursive: true, mode: 0o700 }); + fsMod.writeFileSync(destPath, new Uint8Array(content), { + mode: 0o755, + }); + }; + writeToSetup('gemini.mjs', 'gemini.mjs'); + if (manifest.files) { + for (const file of manifest.files) { + writeToSetup(file.key, file.path); + } + } + try { + fsMod.renameSync(setupDir, finalRuntimeDir); + runtimeDir = finalRuntimeDir; + } catch (renameErr) { + if ( + fsMod.existsSync(finalRuntimeDir) && + isSecure(finalRuntimeDir) && + verifyIntegrity( + finalRuntimeDir, + manifest, + fsMod, + deps.crypto || crypto, + ) + ) { + runtimeDir = finalRuntimeDir; + try { + fsMod.rmSync(setupDir, { recursive: true, force: true }); + } catch {} + } else { + throw renameErr; + } + } + } catch (e) { + console.error( + 'Fatal Error: Failed to setup secure runtime. Please try running again and if error persists please reinstall.', + e, + ); + try { + fsMod.rmSync(setupDir, { recursive: true, force: true }); + } catch {} + process.exit(1); + } + } + + return runtimeDir; +} + +// --- Main Execution --- + +async function main(getAssetFn = getAsset) { + process.env.IS_BINARY = 'true'; + + if (nodeModule.enableCompileCache) { + nodeModule.enableCompileCache(); + } + + process.noDeprecation = true; + + sanitizeArgv(process.argv, process.execPath); + + const manifestJson = getAssetFn('manifest.json', 'utf8'); + if (!manifestJson) { + console.error('Fatal Error: Corrupted binary. Please reinstall.'); + process.exit(1); + } + + const manifest = JSON.parse(manifestJson); + + const runtimeDir = prepareRuntime(manifest, getAssetFn, { + fs, + os, + path, + processEnv: process.env, + crypto, + }); + + const mainPath = path.join(runtimeDir, 'gemini.mjs'); + + await import(pathToFileURL(mainPath).href).catch((err) => { + console.error('Fatal Error: Failed to launch. Please reinstall.', err); + console.error(err); + process.exit(1); + }); +} + +// Only execute if this is the main module (standard Node behavior) +// or if explicitly running as the SEA entry point (heuristic). +if (require.main === module) { + main().catch((err) => { + console.error('Unhandled error in sea-launch:', err); + process.exit(1); + }); +} + +module.exports = { + sanitizeArgv, + getSafeName, + verifyIntegrity, + prepareRuntime, + main, +}; diff --git a/sea/sea-launch.test.js b/sea/sea-launch.test.js new file mode 100644 index 0000000000000000000000000000000000000000..78a142218416fdc0d525e70a90bb8d29c1e7fb16 --- /dev/null +++ b/sea/sea-launch.test.js @@ -0,0 +1,799 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import * as path from 'node:path'; +import { Buffer } from 'node:buffer'; +import process from 'node:process'; +import { + sanitizeArgv, + getSafeName, + verifyIntegrity, + prepareRuntime, + main, +} from './sea-launch.cjs'; + +// Mocking fs and os +// We need to use vi.mock factory for ESM mocking of built-in modules in Vitest +vi.mock('node:fs', async () => { + const fsMock = { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('content'), + lstatSync: vi.fn(), + statSync: vi.fn(), + openSync: vi.fn(), + readSync: vi.fn(), + closeSync: vi.fn(), + }; + return { + default: fsMock, + ...fsMock, + }; +}); +vi.mock('fs', async () => { + const fsMock = { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(), + renameSync: vi.fn(), + rmSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('content'), + lstatSync: vi.fn(), + statSync: vi.fn(), + openSync: vi.fn(), + readSync: vi.fn(), + closeSync: vi.fn(), + }; + return { + default: fsMock, + ...fsMock, + }; +}); + +vi.mock('node:os', async () => { + const osMock = { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }; + return { + default: osMock, + ...osMock, + }; +}); +vi.mock('os', async () => { + const osMock = { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }; + return { + default: osMock, + ...osMock, + }; +}); + +describe('sea-launch', () => { + describe('main', () => { + it('executes main logic', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(globalThis.console, 'error') + .mockImplementation(() => {}); + + const mockGetAsset = vi.fn((key) => { + if (key === 'manifest.json') + return JSON.stringify({ version: '1.0.0', mainHash: 'h1' }); + return Buffer.from('content'); + }); + + await main(mockGetAsset); + + expect(consoleSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalled(); + + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + }); + + describe('sanitizeArgv', () => { + it('removes ghost argument when argv[2] matches execPath', () => { + const execPath = '/bin/node'; + const argv = ['/bin/node', '/app/script.js', '/bin/node', 'arg1']; + const resolveFn = (p) => p; + const removed = sanitizeArgv(argv, execPath, resolveFn); + expect(removed).toBe(true); + expect(argv).toEqual(['/bin/node', '/app/script.js', 'arg1']); + }); + + it('does nothing if argv[2] does not match execPath', () => { + const execPath = '/bin/node'; + const argv = ['/bin/node', '/app/script.js', 'command', 'arg1']; + const resolveFn = (p) => p; + const removed = sanitizeArgv(argv, execPath, resolveFn); + expect(removed).toBe(false); + expect(argv).toHaveLength(4); + }); + + it('handles resolving relative paths', () => { + const execPath = '/bin/node'; + const argv = ['/bin/node', '/app/script.js', './node', 'arg1']; + const resolveFn = (p) => (p === './node' ? '/bin/node' : p); + const removed = sanitizeArgv(argv, execPath, resolveFn); + expect(removed).toBe(true); + }); + }); + + describe('getSafeName', () => { + it('sanitizes strings', () => { + expect(getSafeName('user@name')).toBe('user_name'); + expect(getSafeName('../path')).toBe('.._path'); + expect(getSafeName('valid-1.2')).toBe('valid-1.2'); + expect(getSafeName(undefined)).toBe('unknown'); + }); + }); + + describe('verifyIntegrity', () => { + it('returns true for matching hashes', () => { + const dir = '/tmp/test'; + const manifest = { + mainHash: 'hash1', + files: [{ path: 'file.txt', hash: 'hash2' }], + }; + + const mockFs = { + openSync: vi.fn((p) => { + if (p.endsWith('gemini.mjs')) return 10; + if (p.endsWith('file.txt')) return 20; + throw new Error('Not found'); + }), + readSync: vi.fn((fd, buffer) => { + let content = ''; + if (fd === 10) content = 'content1'; + if (fd === 20) content = 'content2'; + + // Simulate simple read: write content to buffer and return length once, then return 0 + if (!buffer._readDone) { + const buf = Buffer.from(content); + buf.copy(buffer); + buffer._readDone = true; + return buf.length; + } else { + buffer._readDone = false; // Reset for next file + return 0; + } + }), + closeSync: vi.fn(), + }; + + const mockCrypto = { + createHash: vi.fn(() => ({ + update: vi.fn(function (content) { + this._content = + (this._content || '') + Buffer.from(content).toString(); + return this; + }), + digest: vi.fn(function () { + if (this._content === 'content1') return 'hash1'; + if (this._content === 'content2') return 'hash2'; + return 'wrong'; + }), + })), + }; + + expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(true); + }); + + it('returns false for mismatched hashes', () => { + const dir = '/tmp/test'; + const manifest = { mainHash: 'hash1' }; + + const mockFs = { + openSync: vi.fn(() => 10), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + const buf = Buffer.from('content_wrong'); + buf.copy(buffer); + buffer._readDone = true; + return buf.length; + } + return 0; + }), + closeSync: vi.fn(), + }; + + const mockCrypto = { + createHash: vi.fn(() => ({ + update: vi.fn(function (content) { + this._content = + (this._content || '') + Buffer.from(content).toString(); + return this; + }), + digest: vi.fn(function () { + return 'hash_wrong'; + }), + })), + }; + + expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(false); + }); + + it('returns false when fs throws error', () => { + const dir = '/tmp/test'; + const manifest = { mainHash: 'hash1' }; + const mockFs = { + openSync: vi.fn(() => { + throw new Error('FS Error'); + }), + }; + const mockCrypto = { createHash: vi.fn() }; + expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(false); + }); + }); + + describe('prepareRuntime', () => { + const mockManifest = { + version: '1.0.0', + mainHash: 'h1', + files: [{ key: 'f1', path: 'p1', hash: 'h1' }], + }; + const mockGetAsset = vi.fn(); + const S_IFDIR = 0o40000; + const MODE_700 = 0o700; + + it('reuses existing runtime if secure and valid', () => { + const deps = { + fs: { + existsSync: vi.fn(() => true), + rmSync: vi.fn(), + readFileSync: vi.fn(), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 1000, + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }, + path: path, + processEnv: {}, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 1000, + }; + + deps.fs.readFileSync.mockReturnValue('content'); + + const runtime = prepareRuntime(mockManifest, mockGetAsset, deps); + expect(runtime).toContain('gemini-runtime-1.0.0-user'); + expect(deps.fs.rmSync).not.toHaveBeenCalled(); + }); + + it('recreates runtime if existing has wrong owner', () => { + const deps = { + fs: { + existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 999, // Wrong UID + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }, + path: path, + processEnv: {}, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 1000, + processPid: 123, + }; + + mockGetAsset.mockReturnValue(Buffer.from('asset_content')); + + prepareRuntime(mockManifest, mockGetAsset, deps); + + expect(deps.fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-runtime'), + expect.anything(), + ); + expect(deps.fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-setup'), + expect.anything(), + ); + }); + + it('recreates runtime if existing has wrong permissions', () => { + const deps = { + fs: { + existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 1000, + mode: S_IFDIR | 0o777, // Too open + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }, + path: path, + processEnv: {}, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 1000, + processPid: 123, + }; + + mockGetAsset.mockReturnValue(Buffer.from('asset_content')); + + prepareRuntime(mockManifest, mockGetAsset, deps); + + expect(deps.fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-runtime'), + expect.anything(), + ); + }); + + it('creates new runtime if existing is invalid (integrity check)', () => { + const deps = { + fs: { + existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('wrong_content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 1000, + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }, + path: path, + processEnv: {}, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'hash_calculated'), + }; + return hash; + }), + }, + processUid: 1000, + processPid: 123, + }; + + mockGetAsset.mockReturnValue(Buffer.from('asset_content')); + + prepareRuntime(mockManifest, mockGetAsset, deps); + + expect(deps.fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-runtime'), + expect.anything(), + ); + expect(deps.fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-setup'), + expect.anything(), + ); + }); + + it('handles rename race condition: uses target if secure and valid', () => { + const deps = { + fs: { + existsSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(() => { + throw new Error('Rename failed'); + }), + readFileSync: vi.fn().mockReturnValue('content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 1000, + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }, + path: path, + processEnv: {}, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 1000, + processPid: 123, + }; + + // 1. Initial exists check -> false + // 2. mkdir checks (destDir) -> false + // 3. renameSync -> throws + // 4. existsSync (race check) -> true + deps.fs.existsSync + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValue(true); + + mockGetAsset.mockReturnValue(Buffer.from('asset_content')); + + const runtime = prepareRuntime(mockManifest, mockGetAsset, deps); + + expect(deps.fs.renameSync).toHaveBeenCalled(); + expect(runtime).toContain('gemini-runtime'); + expect(deps.fs.rmSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-setup'), + expect.anything(), + ); + }); + + it('handles rename race condition: fails if target is insecure', () => { + const deps = { + fs: { + existsSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(() => { + throw new Error('Rename failed'); + }), + readFileSync: vi.fn().mockReturnValue('content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 999, // Wrong UID + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => '/tmp', + }, + path: path, + processEnv: {}, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 1000, + processPid: 123, + }; + + deps.fs.existsSync + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValue(true); + + mockGetAsset.mockReturnValue(Buffer.from('asset_content')); + + // Mock process.exit and console.error + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {}); + const consoleSpy = vi + .spyOn(globalThis.console, 'error') + .mockImplementation(() => {}); + + prepareRuntime(mockManifest, mockGetAsset, deps); + + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + + it('uses LOCALAPPDATA on Windows if available', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + const deps = { + fs: { + existsSync: vi.fn().mockReturnValue(false), + mkdirSync: vi.fn(), + rmSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 0, + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => 'C:\\Temp', + }, + path: { + join: (...args) => args.join('\\'), + dirname: (p) => p.split('\\').slice(0, -1).join('\\'), + resolve: (p) => p, + }, + processEnv: { + LOCALAPPDATA: 'C:\\Users\\User\\AppData\\Local', + }, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 'unknown', + }; + + prepareRuntime(mockManifest, mockGetAsset, deps); + + expect(deps.fs.mkdirSync).toHaveBeenCalledWith( + 'C:\\Users\\User\\AppData\\Local\\Google\\GeminiCLI', + expect.objectContaining({ recursive: true }), + ); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('falls back to tmpdir on Windows if LOCALAPPDATA is missing', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + const deps = { + fs: { + existsSync: vi.fn().mockReturnValue(false), + mkdirSync: vi.fn(), + rmSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 0, + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => 'C:\\Temp', + }, + path: { + join: (...args) => args.join('\\'), + dirname: (p) => p.split('\\').slice(0, -1).join('\\'), + resolve: (p) => p, + }, + processEnv: {}, // Missing LOCALAPPDATA + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 'unknown', + }; + + const runtime = prepareRuntime(mockManifest, mockGetAsset, deps); + + // Should use tmpdir + expect(runtime).toContain('C:\\Temp'); + expect(runtime).not.toContain('Google\\GeminiCLI'); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('falls back to tmpdir on Windows if mkdir fails', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + + const deps = { + fs: { + existsSync: vi.fn().mockReturnValue(false), + mkdirSync: vi.fn((p) => { + if (typeof p === 'string' && p.includes('Google\\GeminiCLI')) { + throw new Error('Permission denied'); + } + }), + rmSync: vi.fn(), + writeFileSync: vi.fn(), + renameSync: vi.fn(), + readFileSync: vi.fn().mockReturnValue('content'), + openSync: vi.fn(() => 1), + readSync: vi.fn((fd, buffer) => { + if (!buffer._readDone) { + buffer._readDone = true; + return 1; + } + return 0; + }), + closeSync: vi.fn(), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + uid: 0, + mode: S_IFDIR | MODE_700, + })), + }, + os: { + userInfo: () => ({ username: 'user' }), + tmpdir: () => 'C:\\Temp', + }, + path: { + join: (...args) => args.join('\\'), + dirname: (p) => p.split('\\').slice(0, -1).join('\\'), + resolve: (p) => p, + }, + processEnv: { + LOCALAPPDATA: 'C:\\Users\\User\\AppData\\Local', + }, + crypto: { + createHash: vi.fn(() => { + const hash = { + update: vi.fn().mockReturnThis(), + digest: vi.fn(() => 'h1'), + }; + return hash; + }), + }, + processUid: 'unknown', + }; + + const runtime = prepareRuntime(mockManifest, mockGetAsset, deps); + + // Should use tmpdir + expect(runtime).toContain('C:\\Temp'); + expect(deps.fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining('Google\\GeminiCLI'), + expect.anything(), + ); + + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + }); +});