# 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. name: 'Run Gemini CLI' author: 'Google LLC' description: |- Invoke the Gemini CLI from a GitHub Action. inputs: gcp_location: description: 'The Google Cloud location.' required: false gcp_project_id: description: 'The Google Cloud project ID.' required: false gcp_service_account: description: 'The Google Cloud service account email.' required: false gcp_workload_identity_provider: description: 'The Google Cloud Workload Identity Provider.' required: false gcp_token_format: description: 'The token format for authentication. Set to "access_token" to generate access tokens (requires service account), or set to empty string for direct WIF. Can be "access_token" or "id_token".' required: false default: 'access_token' gcp_access_token_scopes: description: 'The access token scopes when using token_format "access_token". Comma-separated list of OAuth 2.0 scopes.' required: false default: 'https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/userinfo.profile' gemini_api_key: description: 'The API key for the Gemini API.' required: false gemini_cli_version: description: 'The version of the Gemini CLI to install. Can be "latest", "preview", "nightly", a specific version number, or a git branch, tag, or commit. For more information, see [Gemini CLI releases](https://github.com/google-gemini/gemini-cli/blob/main/docs/releases.md).' required: false default: 'latest' gemini_debug: description: 'Enable debug logging and output streaming.' required: false gemini_model: description: 'The model to use with Gemini.' required: false google_api_key: description: 'The Vertex AI API key to use with Gemini.' required: false prompt: description: |- A string passed to the Gemini CLI's [`--prompt` argument](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md#command-line-arguments). required: false default: 'You are a helpful assistant.' settings: description: |- A JSON string written to `.gemini/settings.json` to configure the CLI's _project_ settings. For more details, see the documentation on [settings files](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md#settings-files). required: false use_gemini_code_assist: description: |- Whether to use Code Assist for Gemini model access instead of the default Gemini API key. For more information, see the [Gemini CLI documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/authentication.md). required: false default: 'false' use_vertex_ai: description: |- Whether to use Vertex AI for Gemini model access instead of the default Gemini API key. For more information, see the [Gemini CLI documentation](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/authentication.md). required: false default: 'false' extensions: description: 'A list of Gemini CLI extensions to install.' required: false upload_artifacts: description: 'Whether to upload artifacts to the github action.' required: false default: 'false' use_pnpm: description: 'Whether or not to use pnpm instead of npm to install gemini-cli' required: false default: 'false' workflow_name: description: 'The GitHub workflow name, used for telemetry purposes.' required: false default: '${{ github.workflow }}' github_pr_number: description: 'The Pull Request number the CLI is operating on. Defaults to the event payload.' required: false default: '${{ github.event.pull_request.number }}' github_issue_number: description: 'The Issue number (or comma-separated list of issue numbers) the CLI is operating on. Defaults to the event payload.' required: false default: '${{ github.event.issue.number }}' outputs: summary: description: 'The summarized output from the Gemini CLI execution.' value: '${{ steps.gemini_run.outputs.gemini_response }}' error: description: 'The error output from the Gemini CLI execution, if any.' value: '${{ steps.gemini_run.outputs.gemini_errors }}' runs: using: 'composite' steps: - name: 'Validate Inputs' id: 'validate_inputs' shell: 'bash' run: |- set -exuo pipefail # Emit a clear warning in three places without failing the step warn() { local msg="$1" echo "WARNING: ${msg}" >&2 echo "::warning title=Input validation::${msg}" if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { echo "### Input validation warnings" echo echo "- ${msg}" } >> "${GITHUB_STEP_SUMMARY}" fi } # Validate the count of authentication methods auth_methods=0 if [[ "${INPUT_GEMINI_API_KEY_PRESENT:-false}" == "true" ]]; then ((++auth_methods)); fi if [[ "${INPUT_GOOGLE_API_KEY_PRESENT:-false}" == "true" ]]; then ((++auth_methods)); fi if [[ "${INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER_PRESENT:-false}" == "true" ]]; then ((++auth_methods)); fi if [[ ${auth_methods} -eq 0 ]]; then warn "No authentication method provided. Please provide one of 'gemini_api_key', 'google_api_key', or 'gcp_workload_identity_provider'." fi if [[ ${auth_methods} -gt 1 ]]; then warn "Multiple authentication methods provided. Please use only one of 'gemini_api_key', 'google_api_key', or 'gcp_workload_identity_provider'." fi # Validate Workload Identity Federation inputs if [[ "${INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER_PRESENT:-false}" == "true" ]]; then if [[ "${INPUT_GCP_PROJECT_ID_PRESENT:-false}" != "true" ]]; then warn "When using Workload Identity Federation ('gcp_workload_identity_provider'), you must also provide 'gcp_project_id'." fi # Service account is required when using token_format (default behavior) # Only optional when explicitly set to empty for direct WIF if [[ "${INPUT_GCP_TOKEN_FORMAT}" != "" && "${INPUT_GCP_SERVICE_ACCOUNT_PRESENT:-false}" != "true" ]]; then warn "When using Workload Identity Federation with token generation ('gcp_token_format'), you must also provide 'gcp_service_account'. To use direct WIF without a service account, explicitly set 'gcp_token_format' to an empty string." fi if [[ "${INPUT_USE_VERTEX_AI:-false}" == "${INPUT_USE_GEMINI_CODE_ASSIST:-false}" ]]; then warn "When using Workload Identity Federation, you must set exactly one of 'use_vertex_ai' or 'use_gemini_code_assist' to 'true'." fi fi # Validate Vertex AI API Key if [[ "${INPUT_GOOGLE_API_KEY_PRESENT:-false}" == "true" ]]; then if [[ "${INPUT_USE_VERTEX_AI:-false}" != "true" ]]; then warn "When using 'google_api_key', you must set 'use_vertex_ai' to 'true'." fi if [[ "${INPUT_USE_GEMINI_CODE_ASSIST:-false}" == "true" ]]; then warn "When using 'google_api_key', 'use_gemini_code_assist' cannot be 'true'." fi fi # Validate Gemini API Key if [[ "${INPUT_GEMINI_API_KEY_PRESENT:-false}" == "true" ]]; then if [[ "${INPUT_USE_VERTEX_AI:-false}" == "true" || "${INPUT_USE_GEMINI_CODE_ASSIST:-false}" == "true" ]]; then warn "When using 'gemini_api_key', both 'use_vertex_ai' and 'use_gemini_code_assist' must be 'false'." fi fi env: INPUT_GEMINI_API_KEY_PRESENT: "${{ inputs.gemini_api_key != '' }}" INPUT_GOOGLE_API_KEY_PRESENT: "${{ inputs.google_api_key != '' }}" INPUT_GCP_WORKLOAD_IDENTITY_PROVIDER_PRESENT: "${{ inputs.gcp_workload_identity_provider != '' }}" INPUT_GCP_PROJECT_ID_PRESENT: "${{ inputs.gcp_project_id != '' }}" INPUT_GCP_SERVICE_ACCOUNT_PRESENT: "${{ inputs.gcp_service_account != '' }}" INPUT_GCP_TOKEN_FORMAT: '${{ inputs.gcp_token_format }}' INPUT_USE_VERTEX_AI: '${{ inputs.use_vertex_ai }}' INPUT_USE_GEMINI_CODE_ASSIST: '${{ inputs.use_gemini_code_assist }}' - name: 'Sanitize workflow name' id: 'sanitize_workflow_name' shell: 'bash' run: | SANITIZED=$(echo "${WORKFLOW_NAME}" | sed 's/[^ a-zA-Z0-9-]//g' | xargs | tr ' ' '_' | tr '[:upper:]' '[:lower:]') echo "gh_workflow_name=$SANITIZED" >> $GITHUB_OUTPUT env: WORKFLOW_NAME: '${{ inputs.workflow_name }}' - name: 'Configure Gemini CLI' if: |- ${{ inputs.settings != '' }} run: |- mkdir -p .gemini/ echo "${SETTINGS}" > ".gemini/settings.json" shell: 'bash' env: SETTINGS: '${{ inputs.settings }}' - name: 'Install Custom Commands' shell: 'bash' run: |- set -euo pipefail mkdir -p .gemini/commands cp -r "${GITHUB_ACTION_PATH}/.github/commands/"* .gemini/commands/ env: GITHUB_ACTION_PATH: '${{ github.action_path }}' - name: 'Authenticate to Google Cloud' if: |- ${{ inputs.gcp_workload_identity_provider != '' }} id: 'auth' uses: 'google-github-actions/auth@v3' # ratchet:exclude with: project_id: '${{ inputs.gcp_project_id }}' workload_identity_provider: '${{ inputs.gcp_workload_identity_provider }}' service_account: '${{ inputs.gcp_service_account }}' token_format: '${{ inputs.gcp_token_format }}' access_token_scopes: '${{ inputs.gcp_access_token_scopes }}' - name: 'Install pnpm' if: |- ${{ inputs.use_pnpm == 'true' }} uses: 'pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061' # ratchet:pnpm/action-setup@v4 with: version: 10 - name: 'Install Gemini CLI' id: 'install' env: GEMINI_CLI_VERSION: '${{ inputs.gemini_cli_version }}' EXTENSIONS: '${{ inputs.extensions }}' USE_PNPM: '${{ inputs.use_pnpm }}' SURFACE: 'GitHub' GH_WORKFLOW_NAME: '${{ steps.sanitize_workflow_name.outputs.gh_workflow_name }}' GH_PR_NUMBER: '${{ inputs.github_pr_number }}' GH_ISSUE_NUMBER: '${{ inputs.github_issue_number }}' shell: 'bash' run: |- set -euo pipefail VERSION_INPUT="${GEMINI_CLI_VERSION:-latest}" if [[ "${VERSION_INPUT}" == "latest" || "${VERSION_INPUT}" == "preview" || "${VERSION_INPUT}" == "nightly" || "${VERSION_INPUT}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.-]+)?(\+[a-zA-Z0-9\.-]+)?$ ]]; then echo "Installing Gemini CLI from npm: @google/gemini-cli@${VERSION_INPUT}" if [[ "${USE_PNPM}" == "true" ]]; then pnpm add --silent --global @google/gemini-cli@"${VERSION_INPUT}" else npm install --silent --no-audit --prefer-offline --global @google/gemini-cli@"${VERSION_INPUT}" fi else echo "Installing Gemini CLI from GitHub: github:google-gemini/gemini-cli#${VERSION_INPUT}" git clone https://github.com/google-gemini/gemini-cli.git cd gemini-cli git checkout "${VERSION_INPUT}" npm install npm run bundle npm install --silent --no-audit --prefer-offline --global . fi echo "Verifying installation:" if command -v gemini >/dev/null 2>&1; then gemini --version || echo "Gemini CLI installed successfully (version command not available)" else echo "Error: Gemini CLI not found in PATH" exit 1 fi if [[ -n "${EXTENSIONS}" ]]; then echo "Installing Gemini CLI extensions:" echo "${EXTENSIONS}" | jq -r '.[]' | while IFS= read -r extension; do extension=$(echo "${extension}" | xargs) if [[ -n "${extension}" ]]; then echo "Installing ${extension}..." echo "Y" | gemini extensions install "${extension}" fi done fi - name: 'Run Gemini CLI' id: 'gemini_run' shell: 'bash' run: |- set -euo pipefail # Create a temporary directory for storing the output, and ensure it's # cleaned up later TEMP_STDOUT="$(mktemp -p "${RUNNER_TEMP}" gemini-out.XXXXXXXXXX)" TEMP_STDERR="$(mktemp -p "${RUNNER_TEMP}" gemini-err.XXXXXXXXXX)" function cleanup { rm -f "${TEMP_STDOUT}" "${TEMP_STDERR}" } trap cleanup EXIT # Keep track of whether we've failed FAILED=false # Run Gemini CLI with the provided prompt, using JSON output format # We capture stdout (JSON) to TEMP_STDOUT and stderr to TEMP_STDERR if [[ "${GEMINI_DEBUG}" = true ]]; then echo "::warning::Gemini CLI debug logging is enabled. This will stream responses, which could reveal sensitive information if processed with untrusted inputs." echo "::: Start Gemini CLI STDOUT :::" if ! gemini --debug --yolo --prompt "${PROMPT}" --output-format json 2> >(tee "${TEMP_STDERR}" >&2) | tee "${TEMP_STDOUT}"; then FAILED=true fi # Wait for async stderr logging to complete. This is because process substitution in Bash is async so let tee finish writing to ${TEMP_STDERR} sleep 1 echo "::: End Gemini CLI STDOUT :::" else if ! gemini --yolo --prompt "${PROMPT}" --output-format json 2> "${TEMP_STDERR}" 1> "${TEMP_STDOUT}"; then FAILED=true fi fi # Create the artifacts directory and copy full logs mkdir -p gemini-artifacts cp "${TEMP_STDOUT}" gemini-artifacts/stdout.log cp "${TEMP_STDERR}" gemini-artifacts/stderr.log if [[ -f .gemini/telemetry.log ]]; then cp .gemini/telemetry.log gemini-artifacts/telemetry.log else # Create an empty file so the artifact upload doesn't fail if telemetry is missing touch gemini-artifacts/telemetry.log fi # Parse JSON output to extract response and errors # If output is not valid JSON, RESPONSE will be empty and we'll rely on stderr for errors RESPONSE="" ERROR_JSON="" if jq -e . "${TEMP_STDOUT}" >/dev/null 2>&1; then RESPONSE=$(jq -r '.response // ""' "${TEMP_STDOUT}") fi if jq -e . "${TEMP_STDERR}" >/dev/null 2>&1; then ERROR_JSON=$(jq -c '.error // empty' "${TEMP_STDERR}") fi if { [[ -s "${TEMP_STDERR}" ]] && ! jq -e . "${TEMP_STDERR}" >/dev/null 2>&1; }; then echo "::warning::Gemini CLI stderr was not valid JSON" fi if { [[ -s "${TEMP_STDOUT}" ]] && ! jq -e . "${TEMP_STDOUT}" >/dev/null 2>&1; }; then echo "::warning::Gemini CLI stdout was not valid JSON" fi # Set the captured response as a step output, supporting multiline echo "gemini_response<> "${GITHUB_OUTPUT}" if [[ -n "${RESPONSE}" ]]; then echo "${RESPONSE}" >> "${GITHUB_OUTPUT}" else cat "${TEMP_STDOUT}" >> "${GITHUB_OUTPUT}" fi echo "EOF" >> "${GITHUB_OUTPUT}" # Set the captured errors as a step output, supporting multiline echo "gemini_errors<> "${GITHUB_OUTPUT}" if [[ -n "${ERROR_JSON}" ]]; then echo "${ERROR_JSON}" >> "${GITHUB_OUTPUT}" else cat "${TEMP_STDERR}" >> "${GITHUB_OUTPUT}" fi echo "EOF" >> "${GITHUB_OUTPUT}" # Generate Job Summary if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then { echo "### Gemini CLI Execution" echo echo "#### Prompt" echo echo "\`\`\`" echo "${PROMPT}" echo "\`\`\`" echo if [[ -n "${RESPONSE}" ]]; then echo "#### Response" echo echo "${RESPONSE}" echo fi if [[ -n "${ERROR_JSON}" ]]; then echo "#### Error" echo echo "\`\`\`json" echo "${ERROR_JSON}" echo "\`\`\`" echo elif [[ "${FAILED}" == "true" ]]; then echo "#### Error Output" echo echo "\`\`\`" cat "${TEMP_STDERR}" echo "\`\`\`" echo fi } >> "${GITHUB_STEP_SUMMARY}" fi if [[ "${FAILED}" = true ]]; then # If we have a structured error from JSON, use it for the error message if [[ -n "${ERROR_JSON}" ]]; then ERROR_MSG=$(jq -r '.message // .' <<< "${ERROR_JSON}") echo "::error title=Gemini CLI execution failed::${ERROR_MSG}" fi echo "::: Start Gemini CLI STDERR :::" cat "${TEMP_STDERR}" echo "::: End Gemini CLI STDERR :::" exit 1 fi env: GEMINI_DEBUG: '${{ fromJSON(inputs.gemini_debug || false) }}' GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' SURFACE: 'GitHub' GOOGLE_CLOUD_PROJECT: '${{ inputs.gcp_project_id }}' GOOGLE_CLOUD_LOCATION: '${{ inputs.gcp_location }}' GOOGLE_GENAI_USE_VERTEXAI: '${{ inputs.use_vertex_ai }}' GOOGLE_API_KEY: '${{ inputs.google_api_key }}' GOOGLE_GENAI_USE_GCA: '${{ inputs.use_gemini_code_assist }}' GOOGLE_CLOUD_ACCESS_TOKEN: '${{steps.auth.outputs.access_token}}' PROMPT: '${{ inputs.prompt }}' GEMINI_MODEL: '${{ inputs.gemini_model }}' GH_WORKFLOW_NAME: '${{ steps.sanitize_workflow_name.outputs.gh_workflow_name }}' GH_PR_NUMBER: '${{ inputs.github_pr_number }}' GH_ISSUE_NUMBER: '${{ inputs.github_issue_number }}' - name: 'Upload Gemini CLI outputs' if: |- ${{ inputs.upload_artifacts == 'true' }} uses: 'actions/upload-artifact@v6' # ratchet:exclude with: name: 'gemini-output' path: 'gemini-artifacts/' - name: 'Upload Telemetry to Google Cloud' if: |- ${{ inputs.gcp_workload_identity_provider != '' }} shell: 'bash' run: |- set -euo pipefail # If the telemetry log doesn't exist or is empty, do nothing. if [[ ! -s ".gemini/telemetry.log" ]]; then echo "No telemetry log found, skipping upload." exit 0 fi # Generate the real config file from the template sed -e "s#OTLP_GOOGLE_CLOUD_PROJECT#${OTLP_GOOGLE_CLOUD_PROJECT}#g" \ -e "s#GITHUB_REPOSITORY_PLACEHOLDER#${GITHUB_REPOSITORY}#g" \ -e "s#GITHUB_RUN_ID_PLACEHOLDER#${GITHUB_RUN_ID}#g" \ -e "s#GITHUB_PR_NUMBER_PLACEHOLDER#${GITHUB_PR_NUMBER}#g" \ -e "s#GITHUB_ISSUE_NUMBER_PLACEHOLDER#${GITHUB_ISSUE_NUMBER}#g" \ -e "s#GITHUB_WORKFLOW_PLACEHOLDER#${GH_WORKFLOW_NAME}#g" \ "${GITHUB_ACTION_PATH}/scripts/collector-gcp.yaml.template" > ".gemini/collector-gcp.yaml" # Ensure credentials file has the right permissions chmod 444 "$GOOGLE_APPLICATION_CREDENTIALS" # Run the collector in the background with a known name docker run --rm --name gemini-telemetry-collector --network host \ -v "${GITHUB_WORKSPACE}:/github/workspace" \ -e "GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS/$GITHUB_WORKSPACE//github/workspace}" \ -w "/github/workspace" \ otel/opentelemetry-collector-contrib:0.108.0 \ --config /github/workspace/.gemini/collector-gcp.yaml & # Wait for the collector to start up echo "Waiting for collector to initialize..." sleep 10 # Monitor the queue until it's empty or we time out echo "Monitoring exporter queue..." ATTEMPTS=0 MAX_ATTEMPTS=12 # 12 * 5s = 60s timeout while true; do # Use -f to fail silently if the server isn't ready yet # Filter out the prometheus help/type comments before grabbing the value QUEUE_SIZE=$(curl -sf http://localhost:8888/metrics | grep otelcol_exporter_queue_size | grep -v '^#' | awk '{print $2}' || echo "-1") if [ "$QUEUE_SIZE" == "0" ]; then echo "Exporter queue is empty, all data processed." break fi if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then echo "::warning::Timed out waiting for exporter queue to empty. Proceeding with shutdown." break fi echo "Queue size: $QUEUE_SIZE, waiting..." sleep 5 ATTEMPTS=$((ATTEMPTS + 1)) done # Gracefully shut down the collector echo "Stopping collector..." docker stop gemini-telemetry-collector echo "Collector stopped." env: OTLP_GOOGLE_CLOUD_PROJECT: '${{ inputs.gcp_project_id }}' GITHUB_ACTION_PATH: '${{ github.action_path }}' GITHUB_REPOSITORY: '${{ github.repository }}' GITHUB_RUN_ID: '${{ github.run_id }}' GITHUB_PR_NUMBER: '${{ inputs.github_pr_number }}' GITHUB_ISSUE_NUMBER: '${{ inputs.github_issue_number }}' GH_WORKFLOW_NAME: '${{ steps.sanitize_workflow_name.outputs.gh_workflow_name }}' branding: icon: 'terminal' color: 'blue'