|
| 1 | +# Counterfactual prompt replay (#8222, sub-epic #8218, epic #8211 track C). When a PR touches the reviewer |
| 2 | +# judge-prompt surface, this job replays the CANONICAL judge prompt from BOTH the PR's head and base |
| 3 | +# checkouts (scripts/print-review-prompt.ts — the #8139 dual-checkout mechanism) against the recorded |
| 4 | +# raw-context fixture corpus, and posts the Pareto-floored comparison as its own advisory comment. Mirrors |
| 5 | +# backtest-logic-check.yml's posture end-to-end: separate workflow (untouched PRs pay nothing), advisory |
| 6 | +# only (#8105 — never a required check, never blocks merge), every step fails OPEN (notice + green; the |
| 7 | +# review engine auto-closes contributor PRs on ANY red check, so this job's own plumbing must never redden). |
| 8 | +# |
| 9 | +# SPEND GUARD (#8222's requirement): the replay costs real model inference, so the whole run is gated on |
| 10 | +# the deployment explicitly configuring a budget — the COUNTERFACTUAL_REPLAY_BUDGET repo variable (neuron |
| 11 | +# units, see COUNTERFACTUAL_DEFAULT_NEURON_BUDGET) plus the provider endpoint/model below. Unset (the |
| 12 | +# default), the job posts a visible "skipped: no replay budget configured" notice and does nothing else. |
| 13 | +name: counterfactual-replay |
| 14 | + |
| 15 | +on: |
| 16 | + pull_request: |
| 17 | + types: [opened, synchronize, reopened, ready_for_review] |
| 18 | + # Exactly the judge-prompt surface: REVIEW_PROMPT_VERSION + buildSystemPrompt/parseModelReview live |
| 19 | + # here. Keep in sync with #8222's spec. |
| 20 | + paths: |
| 21 | + - "src/services/ai-review.ts" |
| 22 | + |
| 23 | +permissions: |
| 24 | + contents: read |
| 25 | + pull-requests: write |
| 26 | + |
| 27 | +concurrency: |
| 28 | + group: counterfactual-replay-${{ github.ref }} |
| 29 | + cancel-in-progress: true |
| 30 | + |
| 31 | +jobs: |
| 32 | + replay: |
| 33 | + name: counterfactual replay (advisory) |
| 34 | + if: ${{ github.event.pull_request.draft != true && github.event.pull_request.head.repo.fork != true }} |
| 35 | + runs-on: ubuntu-latest |
| 36 | + timeout-minutes: 20 |
| 37 | + steps: |
| 38 | + # The budget gate comes FIRST so an unconfigured deployment pays one echo, not an npm ci. |
| 39 | + - name: Check replay budget configuration |
| 40 | + id: budget |
| 41 | + env: |
| 42 | + REPLAY_BUDGET: ${{ vars.COUNTERFACTUAL_REPLAY_BUDGET }} |
| 43 | + REPLAY_MODEL: ${{ vars.COUNTERFACTUAL_REPLAY_MODEL }} |
| 44 | + REPLAY_PROVIDER_URL: ${{ secrets.COUNTERFACTUAL_OLLAMA_URL }} |
| 45 | + run: | |
| 46 | + if [ -n "$REPLAY_BUDGET" ] && [ -n "$REPLAY_MODEL" ] && [ -n "$REPLAY_PROVIDER_URL" ]; then |
| 47 | + echo "configured=true" >> "$GITHUB_OUTPUT" |
| 48 | + else |
| 49 | + echo "configured=false" >> "$GITHUB_OUTPUT" |
| 50 | + echo "::notice::Counterfactual replay skipped: no replay budget configured (set the COUNTERFACTUAL_REPLAY_BUDGET + COUNTERFACTUAL_REPLAY_MODEL repo variables and the COUNTERFACTUAL_OLLAMA_URL secret to enable). Advisory only, never fails the PR." |
| 51 | + fi |
| 52 | +
|
| 53 | + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 54 | + if: ${{ steps.budget.outputs.configured == 'true' }} |
| 55 | + with: |
| 56 | + persist-credentials: false |
| 57 | + |
| 58 | + # The PR's base commit inside the head workspace — base-side dynamic imports resolve bare specifiers |
| 59 | + # by walking up into the head checkout's node_modules, so one npm ci serves both sides (#8139). |
| 60 | + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 |
| 61 | + if: ${{ steps.budget.outputs.configured == 'true' }} |
| 62 | + with: |
| 63 | + ref: ${{ github.event.pull_request.base.sha }} |
| 64 | + path: .replay-base |
| 65 | + persist-credentials: false |
| 66 | + |
| 67 | + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 |
| 68 | + if: ${{ steps.budget.outputs.configured == 'true' }} |
| 69 | + with: |
| 70 | + node-version-file: .nvmrc |
| 71 | + cache: "npm" |
| 72 | + |
| 73 | + - name: Install deps |
| 74 | + if: ${{ steps.budget.outputs.configured == 'true' }} |
| 75 | + run: npm ci --ignore-scripts |
| 76 | + |
| 77 | + - name: Build engine package |
| 78 | + if: ${{ steps.budget.outputs.configured == 'true' }} |
| 79 | + run: npx turbo run build --filter=@loopover/engine |
| 80 | + |
| 81 | + # Everything below fails OPEN — notice + green, never a red check (#8105 held against our own infra). |
| 82 | + - name: Extract base/head canonical prompts |
| 83 | + if: ${{ steps.budget.outputs.configured == 'true' }} |
| 84 | + id: prompts |
| 85 | + run: | |
| 86 | + if npx tsx scripts/print-review-prompt.ts --root . > head-prompt.txt \ |
| 87 | + && npx tsx scripts/print-review-prompt.ts --root .replay-base > base-prompt.txt \ |
| 88 | + && npx tsx scripts/print-review-prompt.ts --root . --version-only > head-version.txt \ |
| 89 | + && npx tsx scripts/print-review-prompt.ts --root .replay-base --version-only > base-version.txt; then |
| 90 | + if cmp -s head-prompt.txt base-prompt.txt; then |
| 91 | + echo "changed=false" >> "$GITHUB_OUTPUT" |
| 92 | + echo "::notice::Judge prompt surface touched but the canonical prompt text is byte-identical across base/head — nothing to replay." |
| 93 | + else |
| 94 | + echo "changed=true" >> "$GITHUB_OUTPUT" |
| 95 | + fi |
| 96 | + else |
| 97 | + echo "changed=false" >> "$GITHUB_OUTPUT" |
| 98 | + echo "::notice::Prompt extraction failed — replay skipped. Advisory only, never fails the PR." |
| 99 | + fi |
| 100 | +
|
| 101 | + - name: Export corpus from D1 |
| 102 | + if: ${{ steps.budget.outputs.configured == 'true' && steps.prompts.outputs.changed == 'true' }} |
| 103 | + id: corpus |
| 104 | + env: |
| 105 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 106 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 107 | + run: | |
| 108 | + if npx tsx scripts/backtest-corpus-export.ts --rule-id ai_consensus_defect --output replay-corpus.json --remote; then |
| 109 | + echo "available=true" >> "$GITHUB_OUTPUT" |
| 110 | + else |
| 111 | + echo "available=false" >> "$GITHUB_OUTPUT" |
| 112 | + echo "::notice::Corpus export from D1 failed — replay skipped. Advisory only, never fails the PR." |
| 113 | + fi |
| 114 | +
|
| 115 | + # Two runs over the IDENTICAL deterministic sample (seed = head SHA, per the design contract's |
| 116 | + # per-PR determinism requirement): base prompt first (the baseline artifact), then head with |
| 117 | + # --baseline (comparison + comment + persisted run event). |
| 118 | + - name: Replay base and head prompts |
| 119 | + if: ${{ steps.corpus.outputs.available == 'true' }} |
| 120 | + id: replay |
| 121 | + env: |
| 122 | + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 123 | + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} |
| 124 | + REPLAY_BUDGET: ${{ vars.COUNTERFACTUAL_REPLAY_BUDGET }} |
| 125 | + REPLAY_MODEL: ${{ vars.COUNTERFACTUAL_REPLAY_MODEL }} |
| 126 | + REPLAY_PROVIDER_URL: ${{ secrets.COUNTERFACTUAL_OLLAMA_URL }} |
| 127 | + HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| 128 | + BASE_SHA: ${{ github.event.pull_request.base.sha }} |
| 129 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 130 | + run: | |
| 131 | + BASE_VERSION=$(cat base-version.txt) |
| 132 | + HEAD_VERSION=$(cat head-version.txt) |
| 133 | + if npx tsx scripts/counterfactual-replay.ts \ |
| 134 | + --fixtures replay-corpus.json \ |
| 135 | + --variant "${BASE_VERSION}@${REPLAY_MODEL}" \ |
| 136 | + --prompt-file base-prompt.txt \ |
| 137 | + --budget "$REPLAY_BUDGET" \ |
| 138 | + --seed-suffix "$HEAD_SHA" \ |
| 139 | + --ollama-url "$REPLAY_PROVIDER_URL" \ |
| 140 | + --out base-scores.json \ |
| 141 | + && npx tsx scripts/counterfactual-replay.ts \ |
| 142 | + --fixtures replay-corpus.json \ |
| 143 | + --variant "${HEAD_VERSION}@${REPLAY_MODEL}" \ |
| 144 | + --prompt-file head-prompt.txt \ |
| 145 | + --budget "$REPLAY_BUDGET" \ |
| 146 | + --seed-suffix "$HEAD_SHA" \ |
| 147 | + --ollama-url "$REPLAY_PROVIDER_URL" \ |
| 148 | + --baseline base-scores.json \ |
| 149 | + --base-variant-label "$BASE_VERSION" \ |
| 150 | + --comment-out replay-comment.md \ |
| 151 | + --head-sha "$HEAD_SHA" --base-sha "$BASE_SHA" \ |
| 152 | + --persist --remote --db loopover \ |
| 153 | + --repo "$GITHUB_REPOSITORY" --pr "$PR_NUMBER"; then |
| 154 | + echo "ready=true" >> "$GITHUB_OUTPUT" |
| 155 | + else |
| 156 | + echo "ready=false" >> "$GITHUB_OUTPUT" |
| 157 | + echo "::notice::Counterfactual replay failed — no comparison produced. Advisory only, never fails the PR." |
| 158 | + fi |
| 159 | +
|
| 160 | + - name: Post or update the PR comment |
| 161 | + if: ${{ steps.replay.outputs.ready == 'true' }} |
| 162 | + env: |
| 163 | + GH_TOKEN: ${{ github.token }} |
| 164 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 165 | + run: | |
| 166 | + post_comment() { |
| 167 | + marker="<!-- loopover-counterfactual-backtest -->" |
| 168 | + comment_id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \ |
| 169 | + --jq "[.[] | select(.body | contains(\"${marker}\")) | .id] | first // empty" | head -n 1) |
| 170 | + if [ -n "$comment_id" ]; then |
| 171 | + gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" -X PATCH -F body=@replay-comment.md |
| 172 | + else |
| 173 | + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -F body=@replay-comment.md |
| 174 | + fi |
| 175 | + } |
| 176 | + if ! post_comment; then |
| 177 | + echo "::notice::PR comment post failed — replay computed and persisted but not posted. Advisory only, never fails the PR." |
| 178 | + fi |
| 179 | +
|
| 180 | + # Fork half of the paired convention: fork runs get no secrets, so the replay cannot run — say so. |
| 181 | + fork-notice: |
| 182 | + name: counterfactual replay (skipped for fork PRs) |
| 183 | + if: ${{ github.event.pull_request.draft != true && github.event.pull_request.head.repo.fork == true }} |
| 184 | + runs-on: ubuntu-latest |
| 185 | + timeout-minutes: 5 |
| 186 | + steps: |
| 187 | + - name: Explain the skip |
| 188 | + run: echo "::notice::Fork PR — repo secrets are withheld, so the counterfactual replay is skipped. Advisory only; nothing blocks." |
0 commit comments