Codex CLI Upstream Watch #4
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: Codex CLI Upstream Watch | |
| on: | |
| schedule: | |
| - cron: '0 6 * * *' | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| issues: write | |
| concurrency: | |
| group: codex-cli-upstream-watch | |
| cancel-in-progress: false | |
| jobs: | |
| detect-and-open-issue: | |
| name: Detect Codex CLI Upstream Updates | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository with submodules | |
| uses: actions/checkout@v5 | |
| with: | |
| submodules: recursive | |
| fetch-depth: 0 | |
| - name: Detect updates in openai/codex | |
| id: detect | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| SUBMODULE_PATH="submodules/openai-codex" | |
| MODELS_FILE="codex-rs/core/models.json" | |
| CONFIG_SCHEMA_FILE="codex-rs/core/config.schema.json" | |
| FLAG_SOURCE_PATHS=( | |
| "codex-rs/cli/src/main.rs" | |
| "codex-rs/exec/src/cli.rs" | |
| "codex-rs/exec/src/main.rs" | |
| ) | |
| 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" \ | |
| | jq -r '.models[]? | (.id // .slug // empty)' \ | |
| | sed '/^$/d' \ | |
| | sort -u || true | |
| } | |
| 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" --depth=512 | |
| latest_sha="$(git -C "$SUBMODULE_PATH" rev-parse FETCH_HEAD)" | |
| echo "current_sha=$current_sha" >> "$GITHUB_OUTPUT" | |
| echo "latest_sha=$latest_sha" >> "$GITHUB_OUTPUT" | |
| echo "default_branch=$default_branch" >> "$GITHUB_OUTPUT" | |
| if [ "$current_sha" = "$latest_sha" ]; then | |
| echo "No upstream updates." | |
| 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 '^(codex-rs/|docs/|README\.md|CHANGELOG\.md|Cargo\.toml|Cargo\.lock|package\.json)' || 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.json)")" | |
| 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 Codex CLI update | |
| if: steps.detect.outputs.has_update == 'true' | |
| uses: actions/github-script@v7 | |
| env: | |
| CURRENT_SHA: ${{ steps.detect.outputs.current_sha }} | |
| LATEST_SHA: ${{ steps.detect.outputs.latest_sha }} | |
| DEFAULT_BRANCH: ${{ steps.detect.outputs.default_branch }} | |
| 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 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 = `<!-- codexsharp-codex-cli-update:${latestSha} -->`; | |
| const labelName = 'codex-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/openai/codex/compare/${currentSha}...${latestSha}`; | |
| const latestCommitUrl = `https://github.com/openai/codex/commit/${latestSha}`; | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: labelName, | |
| color: '0e8a16', | |
| description: 'Tracks upstream Codex CLI changes from openai/codex', | |
| }); | |
| } 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 Codex CLI upstream changes (${shortCurrent} -> ${shortLatest})`; | |
| const body = [ | |
| marker, | |
| '', | |
| 'Detected upstream updates in `openai/codex` affecting CLI surface tracking.', | |
| '', | |
| `- Submodule path: \`submodules/openai-codex\``, | |
| `- Watched branch: \`${defaultBranch}\``, | |
| `- Current pinned commit: \`${currentSha}\``, | |
| `- Latest upstream commit: \`${latestSha}\``, | |
| `- 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', | |
| '- [ ] Validate latest `codex --help` and `codex exec --help` output', | |
| '- [ ] 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 'Codex 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}`); |