PRが立つたびにCodexがレビューする — Azure OpenAIでGitHub Actionsに組み込んだ話

PRが立つたびにCodexがレビューする — Azure OpenAIでGitHub Actionsに組み込んだ話

PRを立てたら、CodeRabbit、Sourcery、そしてCodexがそれぞれの視点でレビューを返してくる。これが今のmulmoclaudeリポジトリの状態だ。

このうちCodexのレビューは、自前で組んだGitHub Actionsワークフローで動いている。バックエンドは社内のAzure OpenAI。OpenAI公式のAPIではなく、Azureの方を選んだのは単純で、すでにそちらに枠があったからだ。

組み上がるまでに6回の修正PRを必要とした。そのうち最初の数回は、誰がやってもハマる類のもので、これから同じことをやろうとする人のためにメモを残しておく。

やりたかったこと

PRが開かれたら、Codex CLIにdiffを読ませて、

これだけ。フィックスはしない、レビューだけ。CodeRabbitと違って自分でgh CLIを叩くので、コメントの粒度や着眼点を codex exec のプロンプトで好きに設計できる。

初手 — 動くと思った構成

ローカルで codex exec をAzureに向けるのは難しくない。

OPENAI_API_KEY=<azure-key> \
OPENAI_BASE_URL=https://<resource>.openai.azure.com/openai/v1 \
codex exec --sandbox workspace-write "..."

これがちゃんと通る。ならGitHub Actionsで同じ環境変数を渡せば動くはずだ、と思って書いた最初のワークフローは、見事に401を返した。api.openai.com に対して。

つまり OPENAI_BASE_URL が無視されていた。

罠1: Codex CLI 0.125.0 は OPENAI_BASE_URL を読まない

これは仕様らしい。CLIのバージョン0.125.0では、ベースURLを差し替えるには ~/.codex/config.toml でプロバイダを明示的に書くしかない。

model_provider = "azure"
model = "gpt-4o"

[model_providers.azure]
name = "Azure OpenAI"
base_url = "https://<resource>.openai.azure.com/openai/v1"
env_key = "OPENAI_API_KEY"
wire_api = "responses"
query_params = { "api-version" = "preview" }

ワークフロー側では、Configure Codex for Azure OpenAI ステップでこのファイルを書き出す方式にした。

- name: Configure Codex for Azure OpenAI
  env:
    AZURE_BASE_URL: ${{ secrets.AZURE_OPENAI_BASE_URL }}
    AZURE_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION || 'preview' }}
    AZURE_MODEL: ${{ secrets.AZURE_OPENAI_DEPLOYMENT || 'gpt-4o' }}
  run: |
    mkdir -p "$HOME/.codex"
    BASE_URL="${AZURE_BASE_URL%/}"
    cat > "$HOME/.codex/config.toml" <<TOML
    model_provider = "azure"
    model = "${AZURE_MODEL}"

    [model_providers.azure]
    name = "Azure OpenAI"
    base_url = "${BASE_URL}/openai/v1"
    env_key = "OPENAI_API_KEY"
    wire_api = "responses"
    query_params = { "api-version" = "${AZURE_API_VERSION}" }
    TOML

この時点で401は消えた。次は400が来た。

罠2: wire_apiapi-version の組み合わせ

最初に書いた組み合わせ:

wire_api = "responses"
api-version = "2025-04-01-preview"

これでdispatchすると API version not supported で400。「ならGAバージョンに落とすか」と wire_api = "chat" + 2024-10-21 に変えてみる。

すると今度は wire_api = "chat" is no longer supported で起動すらしなくなった。Codex CLI 0.125.0でChat Completions wireのサポートはまるごと削除されている。

仕方なく responses に戻し、api-versionを 2025-03-01-preview に上げる。今度も400。

curlで直接Azureエンドポイントを叩いて確かめた結果が下表だ。

組み合わせ結果
2024-10-21 (GA)400 — API version not supported
2024-10-01-preview2025-04-01-preview (dated)400 — API version not supported
preview (magic alias) + gpt-5.4-mini404 — deployment does not exist
preview + gpt-4o200 OK

つまりこのリソースでは、/openai/v1/responses 経由で受けつけるバージョン文字列は preview という固定の魔法のエイリアスだけ。Microsoftの「ローリング最新」を指す疑似バージョンで、ドキュメントの片隅にひっそり書いてある。

そしてデプロイ名は gpt-5.x ではなく gpt-4o だった(テナント次第)。これは管理者がポータルでつけた名前なので、自分の環境を確認する必要がある。

最終的な値は次の通り。

wire_api = "responses"
query_params = { "api-version" = "preview" }
model = "gpt-4o"

これで「リクエストは通る」段階に到達した。が、ここからまた1段ハマる。

罠3: bubblewrap が GitHub Actions のランナーで死ぬ

dispatch自体は success で終わるようになった。が、PRに何のコメントも付かない。

ログを掘ると、Codexの最後の出力にこう書いてある。

bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted
...
I'm blocked from completing this review because the execution
environment cannot run any shell command (including `gh`).

Codex CLIの workspace-write サンドボックスは、内部でbubblewrapを使ってネットワーク名前空間を作る。そのとき unshare システムコールを呼ぶのだが、GitHub Actionsの非特権ユーザーにはこれが許可されていない。結果、Codexはどんなシェルコマンドも実行できなくなる。gh も含めて。

「実行できなかったので何もせず終わります」という挨拶を残してexit 0で帰ってくるので、ワークフロー自体は緑になる。ハマりやすい。

解決策はサンドボックスを丸ごと外す:

codex exec --sandbox danger-full-access "$(cat <<PROMPT
...
PROMPT
)"

danger-full-access って大丈夫なの?」という問いには、GitHub Actionsのランナー自体がjobごとに使い捨てのVMで隔離されているので、その上に多重サンドボックスを重ねる必要は薄い、という整理で押し切った。少なくともこの用途では。

余談: 検証済みに見えた最初の数回が嘘だった

ここで気付いたのだが、4回目までのdispatchで「verdictがちゃんとPRに付いた!」と思っていたのは、実はローカルで自分が手動で叩いた codex exec の結果がchatgpt-codex-connector というGitHub Appを通してisamu名義で投稿されていただけで、CIからは何も書き込めていなかった。投稿者プロフィールが github-actions[bot] ではなく isamu だったので、後から見ると一発で気付ける。

CIでLLM系のジョブを動かしているとき、出力が不思議に良く見えるときは投稿者プロフィールを見るといい。そこに自分の名前があったら、それはCIの結果ではない。

落ち着いた構成

ここまで踏んでようやく落ち着いた最終形。重要な点だけ抜粋する。

トリガー

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]
    branches: [main]
  workflow_dispatch:
    inputs:
      pr_number:
        description: "PR number to review"
        required: true
        type: string

draftも、docs-onlyも、全部レビューする。フィルタは入れない。コストは別レイヤーで吸収する(後述)。

並走の制御

concurrency:
  group: codex-review-pr-${{ github.event.pull_request.number || inputs.pr_number }}
  cancel-in-progress: true

開発中のPRだと連続でpushすることがある。古い実行を止めて、最新のコミットだけレビューさせる。

CLIのpinとキャッシュ

env:
  CODEX_VERSION: "0.125.0"

# ...

- name: Cache global npm tarballs
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: codex-cli-${{ env.CODEX_VERSION }}-node22

- name: Install codex CLI (pinned)
  run: npm install -g "@openai/codex@${CODEX_VERSION}"

CLIはAPI挙動が版で平気で変わる(さきほどの wire_api = "chat" 削除がいい例)ので、必ずpin。~/.npm をキャッシュしておけば、warmなrunではグローバルインストールがネット越しのダウンロードを省略する。

必要なシークレット

名前用途
AZURE_OPENAI_API_KEY認証キー
AZURE_OPENAI_BASE_URLhttps://<resource>.openai.azure.com(パス無し)
AZURE_OPENAI_DEPLOYMENT任意。デフォルト gpt-4o
AZURE_OPENAI_API_VERSION任意。デフォルト preview
GITHUB_TOKEN自動で渡る。pull-requests: writepermissions: で要求

Codexに渡すプロンプト

ヒアドックで codex exec に渡している。骨子だけ抜粋。

Review PR #${PR_NUMBER} in ${REPO}.

Use the gh CLI to inspect the diff:
  gh pr view ${PR_NUMBER} --json title,body,headRefName
  gh pr diff ${PR_NUMBER}

Before posting your own findings, look at what's already
on the PR — read the existing thread:
  gh api repos/${REPO}/issues/${PR_NUMBER}/comments --paginate
  gh api repos/${REPO}/pulls/${PR_NUMBER}/comments --paginate
For every prior 'CODEX VERDICT: CHANGES REQUESTED' bullet,
check the current diff to decide whether the concern is now
resolved.

Then post inline review comments via:
  gh api repos/${REPO}/pulls/${PR_NUMBER}/comments
or top-level comments via:
  gh pr comment ${PR_NUMBER}

At the END, ALWAYS post ONE final top-level comment that
starts with a verdict marker:
  - 'CODEX VERDICT: LGTM'
  - 'CODEX VERDICT: CHANGES REQUESTED' followed by bullets

Do not apply any fixes yourself — only review and post findings.

verdictマーカーは、別途存在する /codex-cross-review という人間用のskillと同じプロトコルを使うようにした。これによって、人間が手動で走らせるレビューも、CIからのレビューも、見た目とパース対象を揃えられる。

過去のverdictを読ませて「もう直ってる指摘は再掲しない」と明示しているのも実用上重要だった。同じ点を毎pushで何度も言われるとPRが見づらくなる。

コストはどこで止まっているか

PR数が多いリポジトリで「全PRレビュー」をやると、月の請求が怖くなる。実際にこの構成で運用した感触では、3つの層で勝手に止まる。

ガード
ワークフローconcurrency.cancel-in-progress — push連打が1回に圧縮
CLIインストール~/.npm キャッシュ — warm runではダウンロード無し
AzureデプロイTPMキャップ(5000 tokens/min) — 上限超過は429で勝手に止まる

特に3番目が効く。レビュー対象のPRが大きいときは、Codexがdiffを読み込む段階でTPMに当たってリトライしながら進む。爆走することはない。

何が変わったか

CodeRabbitとSourceryに加えて第三の目が入ったことで、PRの様子が少し変わった。それぞれの得意分野が違うので、片方が見落とした点をもう片方が拾うことが普通に起きる。

Codexが特に強く感じるのは「ロジックの一貫性」と「境界条件」だ。たとえば直近の例だと、epochMs の真偽判定が 0 のケースを取りこぼしていた、という指摘をCodexだけがしてきた(if (epochMs)0 がfalsyになる、というやつ)。CodeRabbitは構造的なナットを拾うのが得意で、Sourceryはコードスタイルが強い。重なる部分も多いが、被ったところは「より重要だ」というシグナルとして読める。

まとめ

Codex CLIをGitHub ActionsからAzure OpenAI経由で動かす際にハマるのは、だいたい次の4箇所だ。

  1. OPENAI_BASE_URL が無視される~/.codex/config.toml でプロバイダを明示する
  2. api-version が通らない → Azureリソース次第。preview の魔法エイリアスが通る場合が多い
  3. デプロイ名がモデル名と違う → ポータルで確認、見つからなければ gpt-4o を試す
  4. bubblewrapがランナーで死ぬ--sandbox danger-full-access で外す

このうち1〜3はAzureを使う限り避けられない。4はOpenAI公式エンドポイントを使っていても起きる。

ワークフロー全体はreceptron/mulmoclaude.github/workflows/codex_review.yamlで公開している。試行錯誤の経緯が気になる人は、PR #1059, #1060, #1062, #1063, #1066, #1068 を順に見ると、6回でなんとか落ち着くまでの軌跡がそのまま残っている。

この記事をシェア

関連記事

記事一覧に戻る