Skip to content

Gemini CLI Upstream Watch #30

Gemini CLI Upstream Watch

Gemini CLI Upstream Watch #30

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}`);