|
| 1 | +name: ReviewRouter Execution Internal |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_call: |
| 5 | + inputs: |
| 6 | + review_action_lane: |
| 7 | + description: 'Internal execution lane: legacy or t0' |
| 8 | + required: true |
| 9 | + type: string |
| 10 | + runtime_ref: |
| 11 | + required: true |
| 12 | + type: string |
| 13 | + api_url: |
| 14 | + required: true |
| 15 | + type: string |
| 16 | + runtime_config_mode: |
| 17 | + required: true |
| 18 | + type: string |
| 19 | + static_runtime_env_json: |
| 20 | + required: true |
| 21 | + type: string |
| 22 | + pr_number: |
| 23 | + required: false |
| 24 | + type: string |
| 25 | + default: '' |
| 26 | + review_app_client_id: |
| 27 | + required: false |
| 28 | + type: string |
| 29 | + default: '' |
| 30 | + review_app_repository: |
| 31 | + required: false |
| 32 | + type: string |
| 33 | + default: '' |
| 34 | + provider_instance_id: |
| 35 | + required: false |
| 36 | + type: string |
| 37 | + default: '' |
| 38 | + workflow_schema_version: |
| 39 | + required: true |
| 40 | + type: number |
| 41 | + review_timeout_minutes: |
| 42 | + required: true |
| 43 | + type: number |
| 44 | + max_changed_lines: |
| 45 | + required: true |
| 46 | + type: string |
| 47 | + secrets: |
| 48 | + REVIEW_ROUTER_LEDGER_KEY: |
| 49 | + required: false |
| 50 | + REVIEW_THREAD_LIFECYCLE_RESOLVE_TOKEN: |
| 51 | + required: false |
| 52 | + REVIEW_APP_PRIVATE_KEY: |
| 53 | + required: false |
| 54 | + CODEX_AUTH_JSON: |
| 55 | + required: false |
| 56 | + CODEX_CONFIG_TOML: |
| 57 | + required: false |
| 58 | + CLAUDE_CODE_OAUTH_TOKEN: |
| 59 | + required: false |
| 60 | + OPENAI_API_KEY: |
| 61 | + required: false |
| 62 | + OPENROUTER_API_KEY: |
| 63 | + required: false |
| 64 | + |
| 65 | +jobs: |
| 66 | + review: |
| 67 | + name: review |
| 68 | + runs-on: ubuntu-latest |
| 69 | + timeout-minutes: ${{ inputs.review_timeout_minutes }} |
| 70 | + env: |
| 71 | + RR_REVIEW_ACTION_LANE: ${{ inputs.review_action_lane }} |
| 72 | + RR_RUNTIME_REF: ${{ inputs.runtime_ref }} |
| 73 | + RR_STATIC_RUNTIME_ENV_JSON: ${{ inputs.static_runtime_env_json }} |
| 74 | + RR_WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} |
| 75 | + RR_WORKFLOW_SHA: ${{ job.workflow_sha }} |
| 76 | + REVIEWROUTER_API_URL: ${{ inputs.api_url }} |
| 77 | + REVIEWROUTER_OIDC_AUDIENCE: reviewrouter |
| 78 | + REVIEWROUTER_RUNTIME_CONFIG_MODE: ${{ inputs.runtime_config_mode }} |
| 79 | + REVIEWROUTER_STATIC_CONFIG_FALLBACK: 'true' |
| 80 | + CODEX_AUTH_JSON_PRESENT: ${{ secrets.CODEX_AUTH_JSON != '' && '1' || '0' }} |
| 81 | + CLAUDE_CODE_OAUTH_TOKEN_PRESENT: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' && '1' || '0' }} |
| 82 | + OPENAI_API_KEY_PRESENT: ${{ secrets.OPENAI_API_KEY != '' && '1' || '0' }} |
| 83 | + REVIEW_APP_PRIVATE_KEY_PRESENT: ${{ secrets.REVIEW_APP_PRIVATE_KEY != '' && '1' || '0' }} |
| 84 | + steps: |
| 85 | + - name: Prepare ReviewRouter runtime settings |
| 86 | + id: runtime |
| 87 | + shell: bash |
| 88 | + run: | |
| 89 | + set -euo pipefail |
| 90 | + node <<'NODE' |
| 91 | + const crypto = require('node:crypto'); |
| 92 | + const fs = require('node:fs'); |
| 93 | +
|
| 94 | + const fail = (message) => { |
| 95 | + console.error(`::error::${message}`); |
| 96 | + process.exit(1); |
| 97 | + }; |
| 98 | +
|
| 99 | + const reviewActionLane = (process.env.RR_REVIEW_ACTION_LANE || '').trim(); |
| 100 | + if (!['legacy', 't0'].includes(reviewActionLane)) { |
| 101 | + fail('Invalid internal Review Action execution lane.'); |
| 102 | + } |
| 103 | +
|
| 104 | + const requestedRuntimeRef = (process.env.RR_RUNTIME_REF || '').trim(); |
| 105 | + if (!/^(main|v1|v1\.[0-9]+\.[0-9]+|[a-fA-F0-9]{40})$/.test(requestedRuntimeRef)) { |
| 106 | + fail('Invalid ReviewRouter runtime_ref. Use main, v1, v1.x.x, or a 40-character commit SHA.'); |
| 107 | + } |
| 108 | + const runtimeRepository = |
| 109 | + reviewActionLane === 't0' |
| 110 | + ? (process.env.RR_WORKFLOW_REPOSITORY || '').trim() |
| 111 | + : '777genius/review-router'; |
| 112 | + const runtimeRef = |
| 113 | + reviewActionLane === 't0' |
| 114 | + ? (process.env.RR_WORKFLOW_SHA || '').trim().toLowerCase() |
| 115 | + : requestedRuntimeRef; |
| 116 | + if (reviewActionLane === 't0') { |
| 117 | + if (runtimeRepository !== '777genius/review-router') { |
| 118 | + fail('The T0 runtime must execute from the ReviewRouter reusable workflow repository.'); |
| 119 | + } |
| 120 | + if (!/^[a-f0-9]{40}$/.test(runtimeRef)) { |
| 121 | + fail('The T0 runtime requires the immutable reusable workflow commit SHA.'); |
| 122 | + } |
| 123 | + } |
| 124 | +
|
| 125 | + const runtimeConfigMode = (process.env.REVIEWROUTER_RUNTIME_CONFIG_MODE || '').trim(); |
| 126 | + if (!['oidc', 'static'].includes(runtimeConfigMode)) { |
| 127 | + fail('Invalid ReviewRouter runtime_config_mode. Use oidc or static.'); |
| 128 | + } |
| 129 | +
|
| 130 | + const githubEnv = process.env.GITHUB_ENV; |
| 131 | + const githubOutput = process.env.GITHUB_OUTPUT; |
| 132 | + if (!githubEnv || !githubOutput) { |
| 133 | + fail('GitHub environment files are unavailable.'); |
| 134 | + } |
| 135 | +
|
| 136 | + const appendEnv = (key, value) => { |
| 137 | + if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) { |
| 138 | + fail(`Invalid static runtime env key: ${key}`); |
| 139 | + } |
| 140 | + const text = String(value); |
| 141 | + if (text.includes('\0')) { |
| 142 | + fail(`Invalid static runtime env value for ${key}`); |
| 143 | + } |
| 144 | + const delimiter = `RR_ENV_${key}_${crypto.randomUUID().replace(/-/g, '')}`; |
| 145 | + fs.appendFileSync(githubEnv, `${key}<<${delimiter}\n${text}\n${delimiter}\n`); |
| 146 | + }; |
| 147 | +
|
| 148 | + appendEnv('REVIEWROUTER_ACTION_VERSION', runtimeRef); |
| 149 | + appendEnv( |
| 150 | + 'REVIEWROUTER_COMMENT_TOKEN_MODE', |
| 151 | + runtimeConfigMode === 'oidc' ? 'app-oidc' : 'github-token' |
| 152 | + ); |
| 153 | +
|
| 154 | + let staticEnv; |
| 155 | + try { |
| 156 | + staticEnv = JSON.parse(process.env.RR_STATIC_RUNTIME_ENV_JSON || '{}'); |
| 157 | + } catch (error) { |
| 158 | + fail(`static_runtime_env_json is not valid JSON: ${error.message}`); |
| 159 | + } |
| 160 | + if (!staticEnv || typeof staticEnv !== 'object' || Array.isArray(staticEnv)) { |
| 161 | + fail('static_runtime_env_json must be a JSON object.'); |
| 162 | + } |
| 163 | + if (staticEnv.FAIL_ON_NO_HEALTHY_PROVIDERS === undefined) { |
| 164 | + staticEnv.FAIL_ON_NO_HEALTHY_PROVIDERS = 'true'; |
| 165 | + } |
| 166 | + const staticRuntimeEnvAllowlist = new Set(['TARGET_TOKENS_PER_BATCH']); |
| 167 | + const isSecretLikeStaticRuntimeEnvKey = (key) => |
| 168 | + !staticRuntimeEnvAllowlist.has(key) && |
| 169 | + /(TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|AUTH_JSON)/.test(key); |
| 170 | + for (const [key, value] of Object.entries(staticEnv)) { |
| 171 | + if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) { |
| 172 | + fail(`Invalid static runtime env key: ${key}`); |
| 173 | + } |
| 174 | + if (key === 'REVIEWROUTER_ACTION_V2_MODE') { |
| 175 | + fail('Set review_action_v2_mode on the reusable workflow instead of static_runtime_env_json.'); |
| 176 | + } |
| 177 | + if (isSecretLikeStaticRuntimeEnvKey(key)) { |
| 178 | + fail(`Static runtime env cannot contain secret-like key: ${key}`); |
| 179 | + } |
| 180 | + if (typeof value !== 'string') { |
| 181 | + fail(`Static runtime env value must be a string: ${key}`); |
| 182 | + } |
| 183 | + appendEnv(key, value); |
| 184 | + } |
| 185 | +
|
| 186 | + const appTokenNeeded = |
| 187 | + reviewActionLane === 'legacy' && |
| 188 | + runtimeConfigMode === 'static' && |
| 189 | + process.env.REVIEW_APP_PRIVATE_KEY_PRESENT === '1' && |
| 190 | + (process.env.RR_REVIEW_APP_CLIENT_ID || '').trim().length > 0; |
| 191 | + const eventName = process.env.GITHUB_EVENT_NAME || ''; |
| 192 | + const isForkPullRequest = |
| 193 | + eventName === 'pull_request' && |
| 194 | + process.env.GITHUB_HEAD_REPO_FULL_NAME !== process.env.GITHUB_REPOSITORY; |
| 195 | + const isMergeGroup = eventName === 'merge_group'; |
| 196 | + fs.appendFileSync(githubOutput, `runtime_ref=${runtimeRef}\n`); |
| 197 | + fs.appendFileSync(githubOutput, `runtime_repository=${runtimeRepository}\n`); |
| 198 | + fs.appendFileSync(githubOutput, `can_run=${!isForkPullRequest && !isMergeGroup ? 'true' : 'false'}\n`); |
| 199 | + fs.appendFileSync(githubOutput, `skip_reason=${isMergeGroup ? 'merge_group' : isForkPullRequest ? 'fork' : ''}\n`); |
| 200 | + fs.appendFileSync(githubOutput, `app_token_needed=${appTokenNeeded ? 'true' : 'false'}\n`); |
| 201 | + NODE |
| 202 | + env: |
| 203 | + GITHUB_HEAD_REPO_FULL_NAME: ${{ github.event.pull_request.head.repo.full_name || '' }} |
| 204 | + RR_REVIEW_APP_CLIENT_ID: ${{ inputs.review_app_client_id }} |
| 205 | + |
| 206 | + - name: Skip unsupported review context |
| 207 | + if: ${{ steps.runtime.outputs.can_run == 'false' }} |
| 208 | + shell: bash |
| 209 | + run: | |
| 210 | + if [ "${{ steps.runtime.outputs.skip_reason }}" = "merge_group" ]; then |
| 211 | + echo "ReviewRouter merge queue check passed. Full review runs on pull_request events where a PR number is available." |
| 212 | + else |
| 213 | + echo "ReviewRouter skipped this fork pull request because secret-backed provider execution is disabled by default." |
| 214 | + fi |
| 215 | +
|
| 216 | + - name: Checkout pull request code |
| 217 | + if: ${{ steps.runtime.outputs.can_run == 'true' }} |
| 218 | + uses: actions/checkout@v6 |
| 219 | + with: |
| 220 | + persist-credentials: false |
| 221 | + |
| 222 | + - name: Checkout ReviewRouter runtime |
| 223 | + if: ${{ steps.runtime.outputs.can_run == 'true' }} |
| 224 | + uses: actions/checkout@v6 |
| 225 | + with: |
| 226 | + repository: ${{ steps.runtime.outputs.runtime_repository }} |
| 227 | + ref: ${{ steps.runtime.outputs.runtime_ref }} |
| 228 | + path: .reviewrouter-runtime |
| 229 | + persist-credentials: false |
| 230 | + |
| 231 | + - name: Setup Node.js |
| 232 | + if: ${{ steps.runtime.outputs.can_run == 'true' }} |
| 233 | + uses: actions/setup-node@v6 |
| 234 | + with: |
| 235 | + node-version: '24' |
| 236 | + |
| 237 | + - name: Resolve ReviewRouter runtime provider tooling |
| 238 | + id: provider-tooling |
| 239 | + if: ${{ steps.runtime.outputs.can_run == 'true' }} |
| 240 | + shell: bash |
| 241 | + env: |
| 242 | + REVIEW_ROUTER_MODE: runtime-preflight |
| 243 | + run: node .reviewrouter-runtime/dist/index.js |
| 244 | + |
| 245 | + - name: Create ReviewRouter GitHub App token |
| 246 | + id: app-token |
| 247 | + if: ${{ inputs.review_action_lane == 'legacy' && steps.runtime.outputs.can_run == 'true' && steps.runtime.outputs.app_token_needed == 'true' }} |
| 248 | + uses: actions/create-github-app-token@v3 |
| 249 | + with: |
| 250 | + client-id: ${{ inputs.review_app_client_id }} |
| 251 | + private-key: ${{ secrets.REVIEW_APP_PRIVATE_KEY }} |
| 252 | + owner: ${{ github.repository_owner }} |
| 253 | + repositories: ${{ inputs.review_app_repository }} |
| 254 | + permission-contents: read |
| 255 | + permission-issues: write |
| 256 | + permission-pull-requests: write |
| 257 | + |
| 258 | + - name: Install Codex CLI |
| 259 | + if: ${{ steps.runtime.outputs.can_run == 'true' && steps.provider-tooling.outputs.codex_cli_needed == 'true' }} |
| 260 | + shell: bash |
| 261 | + run: npm install -g @openai/codex@0.125.0 |
| 262 | + |
| 263 | + - name: Install Claude Code CLI |
| 264 | + if: ${{ steps.runtime.outputs.can_run == 'true' && steps.provider-tooling.outputs.claude_cli_needed == 'true' }} |
| 265 | + shell: bash |
| 266 | + run: | |
| 267 | + curl -fsSL https://claude.ai/install.sh | bash |
| 268 | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" |
| 269 | +
|
| 270 | + - name: Restore Codex subscription auth |
| 271 | + if: ${{ steps.runtime.outputs.can_run == 'true' && steps.provider-tooling.outputs.codex_oauth_needed == 'true' && env.CODEX_AUTH_JSON_PRESENT == '1' }} |
| 272 | + shell: bash |
| 273 | + env: |
| 274 | + CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} |
| 275 | + CODEX_CONFIG_TOML: ${{ secrets.CODEX_CONFIG_TOML }} |
| 276 | + run: | |
| 277 | + set -euo pipefail |
| 278 | + node <<'NODE' |
| 279 | + const payload = process.env.CODEX_AUTH_JSON || ''; |
| 280 | + const fail = (message) => { |
| 281 | + console.error('::error::' + message); |
| 282 | + process.exit(1); |
| 283 | + }; |
| 284 | + const warn = (message) => { |
| 285 | + console.error('::warning::' + message); |
| 286 | + }; |
| 287 | + let auth; |
| 288 | + try { |
| 289 | + auth = JSON.parse(payload); |
| 290 | + } catch (error) { |
| 291 | + fail('CODEX_AUTH_JSON is not valid JSON. reseed auth.json from a trusted machine. ' + error.message); |
| 292 | + } |
| 293 | + if (auth.auth_mode !== 'chatgpt') { |
| 294 | + fail('CODEX_AUTH_JSON auth_mode must be chatgpt. reseed auth.json with Codex CLI subscription login or switch this repo to API-key mode.'); |
| 295 | + } |
| 296 | + if (!auth.tokens || typeof auth.tokens.refresh_token !== 'string' || auth.tokens.refresh_token.length === 0) { |
| 297 | + fail('CODEX_AUTH_JSON tokens.refresh_token is missing. Run codex login on a trusted machine and reseed auth.json.'); |
| 298 | + } |
| 299 | + if (!auth.last_refresh) { |
| 300 | + warn('CODEX_AUTH_JSON last_refresh is missing. If Codex later fails with an auth error, run codex login on a trusted machine and reseed auth.json.'); |
| 301 | + } |
| 302 | + NODE |
| 303 | + export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" |
| 304 | + mkdir -p "$CODEX_HOME" |
| 305 | + chmod 700 "$CODEX_HOME" |
| 306 | + printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" |
| 307 | + chmod 600 "$CODEX_HOME/auth.json" |
| 308 | + if [ -n "${CODEX_CONFIG_TOML:-}" ]; then |
| 309 | + printf '%s' "$CODEX_CONFIG_TOML" > "$CODEX_HOME/config.toml" |
| 310 | + chmod 600 "$CODEX_HOME/config.toml" |
| 311 | + fi |
| 312 | +
|
| 313 | + - name: Run ReviewRouter T0 |
| 314 | + if: ${{ inputs.review_action_lane == 't0' && steps.runtime.outputs.can_run == 'true' }} |
| 315 | + shell: bash |
| 316 | + env: |
| 317 | + REVIEWROUTER_ACTION_V2_MODE: t0 |
| 318 | + REVIEW_ROUTER_MODE: codex-oauth-rotating |
| 319 | + INPUT_API_URL: ${{ inputs.api_url }} |
| 320 | + INPUT_PROVIDER_INSTANCE_ID: ${{ inputs.provider_instance_id }} |
| 321 | + INPUT_WORKFLOW_SCHEMA_VERSION: ${{ inputs.workflow_schema_version }} |
| 322 | + INPUT_MAX_CHANGED_LINES: ${{ inputs.max_changed_lines }} |
| 323 | + INPUT_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} |
| 324 | + INPUT_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} |
| 325 | + INPUT_OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} |
| 326 | + REVIEW_ROUTER_LEDGER_KEY: ${{ secrets.REVIEW_ROUTER_LEDGER_KEY }} |
| 327 | + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} |
| 328 | + REVIEW_ROUTER_MEMORY_ENABLED: 'true' |
| 329 | + REVIEW_ROUTER_MEMORY_PROTOCOL_VERSION: '1' |
| 330 | + REVIEW_ROUTER_MEMORY_BUNDLE_ENDPOINT: /api/action/v1/memory |
| 331 | + CODEX_CONFIG_TOML: ${{ secrets.CODEX_CONFIG_TOML }} |
| 332 | + run: node .reviewrouter-runtime/dist/index.js |
| 333 | + |
| 334 | + - name: Run ReviewRouter legacy |
| 335 | + if: ${{ inputs.review_action_lane == 'legacy' && steps.runtime.outputs.can_run == 'true' }} |
| 336 | + shell: bash |
| 337 | + env: |
| 338 | + REVIEWROUTER_ACTION_V2_MODE: disabled |
| 339 | + GITHUB_TOKEN: ${{ steps.app-token.outputs.token || github.token }} |
| 340 | + REVIEW_ROUTER_LEDGER_KEY: ${{ secrets.REVIEW_ROUTER_LEDGER_KEY }} |
| 341 | + REVIEW_THREAD_LIFECYCLE_RESOLVE_TOKEN: ${{ secrets.REVIEW_THREAD_LIFECYCLE_RESOLVE_TOKEN }} |
| 342 | + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} |
| 343 | + REVIEW_ROUTER_MEMORY_ENABLED: 'true' |
| 344 | + REVIEW_ROUTER_MEMORY_PROTOCOL_VERSION: '1' |
| 345 | + REVIEW_ROUTER_MEMORY_BUNDLE_ENDPOINT: /api/action/v1/memory |
| 346 | + CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} |
| 347 | + CODEX_CONFIG_TOML: ${{ secrets.CODEX_CONFIG_TOML }} |
| 348 | + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} |
| 349 | + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} |
| 350 | + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} |
| 351 | + run: node .reviewrouter-runtime/dist/index.js |
0 commit comments