Gemini CLI Upstream Watch #30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Gemini CLI Upstream Watch | |
| on: | |
| schedule: | |
| - cron: '0 6 * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| concurrency: | |
| group: gemini-cli-upstream-watch | |
| cancel-in-progress: false | |
| jobs: | |
| detect-and-open-issue: | |
| name: Detect Gemini CLI Upstream Updates | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository with submodules | |
| uses: actions/checkout@v6.0.2 | |
| with: | |
| submodules: recursive | |
| fetch-depth: 0 | |
| - name: Detect updates in google/gemini-cli | |
| id: detect | |
| shell: bash | |
| env: | |
| GITHUB_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| UPSTREAM_REPO="google-gemini/gemini-cli" | |
| SUBMODULE_PATH="submodules/google-gemini-cli" | |
| MODELS_FILE="packages/core/src/config/models.ts" | |
| CONFIG_SCHEMA_FILE="schemas/settings.schema.json" | |
| FLAG_SOURCE_PATHS=( | |
| "packages/cli/src/gemini.tsx" | |
| "packages/cli/src/config/config.ts" | |
| "packages/core/src/config/config.ts" | |
| ) | |
| CLI_RELEVANT_PATHS_REGEX='^(packages/|schemas/|gemini-rs/|docs/|README\.md|CHANGELOG\.md|package\.json|package-lock\.json|pnpm-lock\.yaml|bun\.lockb?|Cargo\.toml|Cargo\.lock)' | |
| read_file_at_sha() { | |
| local sha="$1" | |
| local path="$2" | |
| git -C "$SUBMODULE_PATH" show "$sha:$path" 2>/dev/null || true | |
| } | |
| extract_flag_snapshot() { | |
| local sha="$1" | |
| local path | |
| for path in "${FLAG_SOURCE_PATHS[@]}"; do | |
| read_file_at_sha "$sha" "$path" | |
| done | grep -Eo -- '--[a-z0-9][a-z0-9-]*' | sort -u || true | |
| } | |
| extract_model_snapshot() { | |
| local sha="$1" | |
| read_file_at_sha "$sha" "$MODELS_FILE" | python3 -c ' | |
| import re | |
| import sys | |
| path = sys.argv[1] | |
| content = sys.stdin.read() | |
| quote = chr(39) | |
| quoted_value_pattern = "[\"" + quote + "]([^\"" + quote + "]+)[\"" + quote + "]" | |
| matches = [] | |
| if path.endswith("models.ts"): | |
| models_set_match = re.search( | |
| r"export\s+const\s+VALID_GEMINI_MODELS\s*=\s*new\s+Set\s*\(\s*\[(.*?)\]\s*\)", | |
| content, | |
| re.S, | |
| ) | |
| if models_set_match: | |
| matches = re.findall(quoted_value_pattern, models_set_match.group(1)) | |
| if not matches: | |
| matches = re.findall( | |
| r"export\s+const\s+[A-Z0-9_]*_MODEL[A-Z0-9_]*\s*=\s*" + quoted_value_pattern, | |
| content, | |
| ) | |
| else: | |
| matches = re.findall( | |
| r"export\s+const\s+[^\s]+\s*=\s*" + quoted_value_pattern, | |
| content, | |
| ) | |
| for value in sorted(set(matches)): | |
| print(value) | |
| ' "$MODELS_FILE" | |
| } | |
| extract_feature_snapshot() { | |
| local sha="$1" | |
| read_file_at_sha "$sha" "$CONFIG_SCHEMA_FILE" \ | |
| | jq -r '.properties.features.properties? | keys[]?' \ | |
| | sed '/^$/d' \ | |
| | sort -u || true | |
| } | |
| format_snapshot_changes() { | |
| local before_file="$1" | |
| local after_file="$2" | |
| local empty_message="$3" | |
| local added | |
| local removed | |
| local formatted="" | |
| added="$(comm -13 "$before_file" "$after_file" | sed 's/^/- added `/' | sed 's/$/`/' || true)" | |
| removed="$(comm -23 "$before_file" "$after_file" | sed 's/^/- removed `/' | sed 's/$/`/' || true)" | |
| if [ -n "$added" ]; then | |
| formatted="$added" | |
| fi | |
| if [ -n "$removed" ]; then | |
| if [ -n "$formatted" ]; then | |
| formatted="$formatted"$'\n'"$removed" | |
| else | |
| formatted="$removed" | |
| fi | |
| fi | |
| if [ -z "$formatted" ]; then | |
| formatted="$empty_message" | |
| fi | |
| printf '%s\n' "$formatted" | |
| } | |
| if [ ! -d "$SUBMODULE_PATH/.git" ] && [ ! -f "$SUBMODULE_PATH/.git" ]; then | |
| echo "Submodule not initialized: $SUBMODULE_PATH" | |
| echo "has_update=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| current_sha="$(git -C "$SUBMODULE_PATH" rev-parse HEAD)" | |
| default_branch="$(git -C "$SUBMODULE_PATH" remote show origin | sed -n '/HEAD branch/s/.*: //p')" | |
| if [ -z "$default_branch" ]; then | |
| default_branch="main" | |
| fi | |
| git -C "$SUBMODULE_PATH" fetch origin "$default_branch" --tags --depth=512 | |
| latest_release_json="$(curl -fsSL \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -H "Authorization: Bearer $GITHUB_TOKEN" \ | |
| "https://api.github.com/repos/$UPSTREAM_REPO/releases/latest")" | |
| latest_release_tag="$(printf '%s' "$latest_release_json" | jq -r '.tag_name // empty')" | |
| latest_release_name="$(printf '%s' "$latest_release_json" | jq -r '.name // empty')" | |
| latest_release_url="$(printf '%s' "$latest_release_json" | jq -r '.html_url // empty')" | |
| latest_release_published_at="$(printf '%s' "$latest_release_json" | jq -r '.published_at // empty')" | |
| if [ -z "$latest_release_tag" ]; then | |
| echo "Could not resolve latest upstream release tag for $UPSTREAM_REPO" >&2 | |
| exit 1 | |
| fi | |
| latest_sha="$(git -C "$SUBMODULE_PATH" rev-list -n 1 "refs/tags/$latest_release_tag^{commit}")" | |
| current_release_tag="$(git -C "$SUBMODULE_PATH" describe --tags --exact-match "$current_sha" 2>/dev/null || true)" | |
| echo "current_sha=$current_sha" >> "$GITHUB_OUTPUT" | |
| echo "latest_sha=$latest_sha" >> "$GITHUB_OUTPUT" | |
| echo "default_branch=$default_branch" >> "$GITHUB_OUTPUT" | |
| echo "current_release_tag=$current_release_tag" >> "$GITHUB_OUTPUT" | |
| echo "latest_release_tag=$latest_release_tag" >> "$GITHUB_OUTPUT" | |
| echo "latest_release_name=$latest_release_name" >> "$GITHUB_OUTPUT" | |
| echo "latest_release_url=$latest_release_url" >> "$GITHUB_OUTPUT" | |
| echo "latest_release_published_at=$latest_release_published_at" >> "$GITHUB_OUTPUT" | |
| if [ "$current_sha" = "$latest_sha" ] || git -C "$SUBMODULE_PATH" merge-base --is-ancestor "$latest_sha" "$current_sha"; then | |
| echo "No newer upstream release beyond pinned submodule commit." | |
| echo "has_update=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| changed_files="$(git -C "$SUBMODULE_PATH" diff --name-only "$current_sha" "$latest_sha")" | |
| if [ -z "$changed_files" ]; then | |
| echo "has_update=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| echo "has_update=true" >> "$GITHUB_OUTPUT" | |
| cli_changed_files="$(printf '%s\n' "$changed_files" | grep -E "$CLI_RELEVANT_PATHS_REGEX" || true)" | |
| if [ -z "$cli_changed_files" ]; then | |
| cli_changed_files="$(printf '%s\n' "$changed_files" | head -n 200)" | |
| fi | |
| commits="$(git -C "$SUBMODULE_PATH" log --no-merges --date=short --pretty=format:'- %h %s (%ad)' "$current_sha..$latest_sha" | head -n 100)" | |
| if [ -z "$commits" ]; then | |
| commits='- No commit summary available.' | |
| fi | |
| tmp_dir="$(mktemp -d)" | |
| trap 'rm -rf "$tmp_dir"' EXIT | |
| extract_flag_snapshot "$current_sha" > "$tmp_dir/flags-before.txt" | |
| extract_flag_snapshot "$latest_sha" > "$tmp_dir/flags-after.txt" | |
| extract_model_snapshot "$current_sha" > "$tmp_dir/models-before.txt" | |
| extract_model_snapshot "$latest_sha" > "$tmp_dir/models-after.txt" | |
| extract_feature_snapshot "$current_sha" > "$tmp_dir/features-before.txt" | |
| extract_feature_snapshot "$latest_sha" > "$tmp_dir/features-after.txt" | |
| changed_flags="$(format_snapshot_changes \ | |
| "$tmp_dir/flags-before.txt" \ | |
| "$tmp_dir/flags-after.txt" \ | |
| "- (no CLI flag surface change detected from CLI sources)")" | |
| changed_models="$(format_snapshot_changes \ | |
| "$tmp_dir/models-before.txt" \ | |
| "$tmp_dir/models-after.txt" \ | |
| "- (no model catalog change detected from models.ts)")" | |
| changed_features="$(format_snapshot_changes \ | |
| "$tmp_dir/features-before.txt" \ | |
| "$tmp_dir/features-after.txt" \ | |
| "- (no feature flag surface change detected from config schema)")" | |
| { | |
| echo "cli_changed_files<<EOF" | |
| echo "$cli_changed_files" | |
| echo "EOF" | |
| echo "commits<<EOF" | |
| echo "$commits" | |
| echo "EOF" | |
| echo "changed_flags<<EOF" | |
| echo "$changed_flags" | |
| echo "EOF" | |
| echo "changed_models<<EOF" | |
| echo "$changed_models" | |
| echo "EOF" | |
| echo "changed_features<<EOF" | |
| echo "$changed_features" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Create issue for Gemini CLI update | |
| if: steps.detect.outputs.has_update == 'true' | |
| uses: actions/github-script@v8 | |
| env: | |
| CURRENT_SHA: ${{ steps.detect.outputs.current_sha }} | |
| LATEST_SHA: ${{ steps.detect.outputs.latest_sha }} | |
| DEFAULT_BRANCH: ${{ steps.detect.outputs.default_branch }} | |
| CURRENT_RELEASE_TAG: ${{ steps.detect.outputs.current_release_tag }} | |
| LATEST_RELEASE_TAG: ${{ steps.detect.outputs.latest_release_tag }} | |
| LATEST_RELEASE_NAME: ${{ steps.detect.outputs.latest_release_name }} | |
| LATEST_RELEASE_URL: ${{ steps.detect.outputs.latest_release_url }} | |
| LATEST_RELEASE_PUBLISHED_AT: ${{ steps.detect.outputs.latest_release_published_at }} | |
| CLI_CHANGED_FILES: ${{ steps.detect.outputs.cli_changed_files }} | |
| COMMITS: ${{ steps.detect.outputs.commits }} | |
| CHANGED_FLAGS: ${{ steps.detect.outputs.changed_flags }} | |
| CHANGED_MODELS: ${{ steps.detect.outputs.changed_models }} | |
| CHANGED_FEATURES: ${{ steps.detect.outputs.changed_features }} | |
| ASSIGNEE: ${{ vars.SDK_SYNC_ASSIGNEE != '' && vars.SDK_SYNC_ASSIGNEE || 'copilot' }} | |
| with: | |
| script: | | |
| const currentSha = process.env.CURRENT_SHA; | |
| const latestSha = process.env.LATEST_SHA; | |
| const defaultBranch = process.env.DEFAULT_BRANCH; | |
| const currentReleaseTag = process.env.CURRENT_RELEASE_TAG || ''; | |
| const latestReleaseTag = process.env.LATEST_RELEASE_TAG || ''; | |
| const latestReleaseName = process.env.LATEST_RELEASE_NAME || ''; | |
| const latestReleaseUrl = process.env.LATEST_RELEASE_URL || ''; | |
| const latestReleasePublishedAt = process.env.LATEST_RELEASE_PUBLISHED_AT || ''; | |
| const changedFilesRaw = process.env.CLI_CHANGED_FILES || ''; | |
| const commitsRaw = process.env.COMMITS || ''; | |
| const changedFlags = process.env.CHANGED_FLAGS || '- (not provided)'; | |
| const changedModels = process.env.CHANGED_MODELS || '- (not provided)'; | |
| const changedFeatures = process.env.CHANGED_FEATURES || '- (not provided)'; | |
| const assignee = process.env.ASSIGNEE || 'copilot'; | |
| const shortCurrent = currentSha.slice(0, 7); | |
| const shortLatest = latestSha.slice(0, 7); | |
| const marker = `<!-- geminisharp-gemini-cli-release:${latestReleaseTag}:${latestSha} -->`; | |
| const labelName = 'gemini-cli-sync'; | |
| const changedFiles = changedFilesRaw | |
| .split('\n') | |
| .map(x => x.trim()) | |
| .filter(Boolean) | |
| .slice(0, 200) | |
| .map(x => `- \`${x}\``) | |
| .join('\n'); | |
| const commits = commitsRaw.trim() || '- No commit summary available.'; | |
| const compareUrl = `https://github.com/google-gemini/gemini-cli/compare/${currentSha}...${latestSha}`; | |
| const latestCommitUrl = `https://github.com/google-gemini/gemini-cli/commit/${latestSha}`; | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: labelName, | |
| color: '0e8a16', | |
| description: 'Tracks upstream Gemini CLI changes from google-gemini/gemini-cli', | |
| }); | |
| } catch (error) { | |
| core.info(`Label create skipped: ${error.message}`); | |
| } | |
| const openIssues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| labels: labelName, | |
| per_page: 100, | |
| }); | |
| const duplicate = openIssues.find(issue => | |
| !issue.pull_request && issue.body && issue.body.includes(marker) | |
| ); | |
| if (duplicate) { | |
| core.info(`Issue already exists for ${latestSha}: #${duplicate.number}`); | |
| return; | |
| } | |
| const title = `Sync Gemini CLI release ${latestReleaseTag} (${shortCurrent} -> ${shortLatest})`; | |
| const body = [ | |
| marker, | |
| '', | |
| 'Detected a newer upstream release in `google-gemini/gemini-cli` affecting CLI surface tracking.', | |
| '', | |
| `- Submodule path: \`submodules/google-gemini-cli\``, | |
| `- Watched branch: \`${defaultBranch}\``, | |
| `- Current pinned release tag: ${currentReleaseTag ? `\`${currentReleaseTag}\`` : '_not pinned to an exact release tag_'}`, | |
| `- Current pinned commit: \`${currentSha}\``, | |
| `- Latest upstream release tag: \`${latestReleaseTag}\``, | |
| `- Latest upstream release name: ${latestReleaseName || '_not provided_'}`, | |
| `- Latest upstream commit: \`${latestSha}\``, | |
| `- Release published at: ${latestReleasePublishedAt || '_not provided_'}`, | |
| `- Release page: ${latestReleaseUrl || '_not provided_'}`, | |
| `- Compare: ${compareUrl}`, | |
| `- Latest commit: ${latestCommitUrl}`, | |
| '', | |
| '## Changed files (CLI-relevant)', | |
| changedFiles || '- (No file list available)', | |
| '', | |
| '## Detected CLI flag surface changes', | |
| changedFlags, | |
| '', | |
| '## Detected model catalog changes', | |
| changedModels, | |
| '', | |
| '## Detected feature flag changes', | |
| changedFeatures, | |
| '', | |
| '## Commits', | |
| commits, | |
| '', | |
| '## Action required', | |
| '- [ ] Review upstream release notes and confirm breaking/non-breaking CLI changes', | |
| '- [ ] Validate latest `gemini --help` and headless `gemini --prompt ... --output-format stream-json` output', | |
| '- [ ] Update pinned `submodules/google-gemini-cli` release/tag after validation', | |
| '- [ ] Sync C# SDK constants/options/models with upstream CLI changes', | |
| '- [ ] Add or update tests for new flags/models/features', | |
| '- [ ] Update docs (README + docs/Features + docs/Architecture if needed)', | |
| '', | |
| `_Opened automatically by scheduled workflow 'Gemini CLI Upstream Watch'._`, | |
| ].join('\n'); | |
| let issue; | |
| try { | |
| issue = await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title, | |
| body, | |
| labels: [labelName], | |
| assignees: [assignee], | |
| }); | |
| core.info(`Created and assigned issue #${issue.data.number} to @${assignee}`); | |
| } catch (error) { | |
| core.warning(`Issue assignment failed (${error.message}), creating without assignee.`); | |
| issue = await github.rest.issues.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| title, | |
| body, | |
| labels: [labelName], | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issue.data.number, | |
| body: `Could not auto-assign @${assignee}. Please assign manually.`, | |
| }); | |
| } | |
| core.info(`Issue URL: ${issue.data.html_url}`); |