-
-
Notifications
You must be signed in to change notification settings - Fork 57
416 lines (390 loc) · 18.4 KB
/
Copy pathcontract-drift-autofix.yml
File metadata and controls
416 lines (390 loc) · 18.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
name: "Contract Drift Autofix"
# Refs: #1273 - CI hardening, bucket CI-E.
#
# Why this workflow exists
# ------------------------
# Three contract tests in tests/unit/ act as drift detectors:
#
# * test_readme_api_coverage.py::test_all_cli_commands_are_documented
# fails when a new top-level CLI command is registered but not added to
# DOCUMENTED_COMMANDS.
# * test_api_v1_routing.py::TestVersionedRoutesParity::test_every_root_route_has_v1_counterpart
# fails when a new root-mounted FastAPI route is added without either a
# /api/v1/* mirror or an entry in _INFRASTRUCTURE_PATHS.
# * test_cli_run_params.py::test_run_params_match_cli_call
# fails when run() gains a new parameter that the cli() click callback
# does not forward.
#
# Every one of these is a 1-2 line allow-list / forward-arg edit. The fix is
# mechanical and well-suited for an autofix bot.
#
# Strategy (modernized, refs operator audit 2026-05-18)
# -----------------------------------------------------
# Inline-fix model: when drift is detected on a same-repo PR, commit the
# regen DIRECTLY to the source PR's head ref instead of opening a separate
# bot PR. This eliminates the entire class of orphaned bot-PR churn
# (#1436, #1440, #1441, #1445 type) we previously saw, because:
#
# * No separate PR means no separate review/merge step.
# * The regen rides into main with the source PR, atomically.
# * When the source PR is auto-deleted on merge, there is no orphaned
# bot branch left re-targeting to main with a giant diff.
#
# Fallback paths (preserved for safety):
# * Fork PRs (no push to head ref): post the patch as an idempotent PR
# comment with copy-paste-able apply instructions.
# * Unhandled drift / LOC-cap trip: open a tracking issue.
# * Push to head ref fails (branch protection, lease conflict): post
# the patch as a PR comment so the operator can apply locally.
#
# Recursion guards
# ----------------
# * Skips when the PR author is the bot itself (no infinite loops).
# * Skips PRs whose head ref starts with `bot/contract-drift-`
# (legacy bot branches; ignored so they don't re-trigger).
# * Diff size capped at 30 LOC by the regen script; larger diffs imply
# a real change and are escalated by opening an issue instead.
# * No `actions: write` permission, so the autofix commit cannot
# recursively trigger more workflows in this file (the inline commit
# uses BOT_PAT to bypass the GITHUB_TOKEN recursion mute for the
# source PR's other workflows; this file is muted for itself via the
# head-ref recursion guard above and the bot-author check).
#
# Secret requirements
# -------------------
# * BOT_PAT (optional, recommended): a fine-grained PAT with `contents:
# write` + `pull-requests: write` on this repo. When present, the
# inline push triggers CI runs on the source PR (GITHUB_TOKEN-authored
# commits are otherwise muted by GitHub's recursion protection).
# When absent, falls back to GITHUB_TOKEN and the PR-comment path
# (the operator applies the regen manually).
on:
pull_request:
types: [opened, synchronize]
concurrency:
group: contract-drift-${{ github.event.pull_request.number }}
cancel-in-progress: true
# Workflow-scope permissions: contents:write for the inline regen push to
# the source PR head ref, pull-requests:write for the comment-fallback
# path, issues:write for the tracking-issue fallback when regen cannot
# produce a clean patch. The autofix job re-asserts the same set at job
# scope (Scorecard token-permissions).
permissions:
contents: write
pull-requests: write
issues: write
jobs:
autofix:
name: Detect and patch contract drift
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
issues: write
# Recursion guard: don't run on PRs the bot itself opened, and don't
# run on legacy bot-contract-drift-* branches (kept for safety while
# any old open bot PRs drain).
if: >
github.event.pull_request.user.login != 'github-actions[bot]' &&
github.event.pull_request.user.login != 'bernstein[bot]' &&
github.event.pull_request.user.login != 'bernstein-orchestrator[bot]' &&
!startsWith(github.event.pull_request.head.ref, 'bot/contract-drift-')
steps:
- name: Harden runner (audit mode)
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- name: Detect fork PR
id: forkcheck
env:
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
run: |
# Fork PRs have head.repo != base repo. We cannot push to a fork's
# head ref, so we fall back to the PR-comment path for those.
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
echo "is_fork=true" >> "$GITHUB_OUTPUT"
echo "::notice::PR is from a fork ($HEAD_REPO); will use PR-comment fallback if drift detected."
else
echo "is_fork=false" >> "$GITHUB_OUTPUT"
fi
# persist-credentials kept: this job pushes the regen commit back to
# the source PR's head ref (same-repo PRs only).
- name: Checkout PR head
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # zizmor: ignore[artipacked]
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
# Use BOT_PAT if present so the inline push triggers CI on the
# source PR. GITHUB_TOKEN-authored pushes are muted by GitHub.
token: ${{ secrets.BOT_PAT || secrets.GITHUB_TOKEN }}
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Sync project (dev group)
run: uv sync --group dev
- name: Run drift tests (capture failures, don't fail the job)
id: drift
run: |
set +e
uv run pytest \
tests/unit/test_readme_api_coverage.py::test_all_cli_commands_are_documented \
tests/unit/test_api_v1_routing.py::TestVersionedRoutesParity::test_every_root_route_has_v1_counterpart \
tests/unit/test_cli_run_params.py::test_run_params_match_cli_call \
--tb=short
status=$?
echo "status=$status" >> "$GITHUB_OUTPUT"
if [ "$status" -eq 0 ]; then
echo "drift=false" >> "$GITHUB_OUTPUT"
echo "::notice::No contract drift detected."
else
echo "drift=true" >> "$GITHUB_OUTPUT"
echo "::notice::Contract drift detected - running regen."
fi
exit 0
- name: Regenerate drift fixtures
if: steps.drift.outputs.drift == 'true'
id: regen
run: |
set +e
uv run python scripts/regen_contract_drift.py --fixture all
# The regen script returns 0 only when at least one fixture was
# patched. Any non-zero exit means nothing was patched - either
# because the drift wasn't one of the three known patterns or
# because the diff exceeded the 30-LOC safety cap.
regen_status=$?
echo "regen_status=$regen_status" >> "$GITHUB_OUTPUT"
exit 0
- name: Verify regen produced something
if: steps.drift.outputs.drift == 'true'
id: verify
run: |
if git diff --quiet; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "::warning::Drift was detected but regen produced no diff. Likely an unhandled drift pattern or LOC-cap trip - opening tracking issue."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
# Compute a coarse summary of what was regenerated.
CHANGED_FILES=$(git diff --name-only | sort -u)
{
echo "changed_files<<EOF"
echo "$CHANGED_FILES"
echo "EOF"
} >> "$GITHUB_OUTPUT"
# Compute LOC delta (added+removed) for the safety report.
STAT=$(git diff --shortstat)
INS=$(echo "$STAT" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' || echo "0")
DEL=$(echo "$STAT" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0")
LOC=$((INS + DEL))
echo "loc_delta=${LOC}" >> "$GITHUB_OUTPUT"
echo "::notice::Regen produced ${LOC:-0} LOC of changes across:"
while IFS= read -r f; do
[ -z "$f" ] && continue
echo " $f"
done <<< "$CHANGED_FILES"
# Persist the diff for the comment-fallback path.
git diff > "${RUNNER_TEMP}/contract-drift.patch"
fi
- name: Re-run drift tests to confirm regen fixed them
if: steps.drift.outputs.drift == 'true' && steps.verify.outputs.changed == 'true'
id: reverify
run: |
set +e
uv run pytest \
tests/unit/test_readme_api_coverage.py::test_all_cli_commands_are_documented \
tests/unit/test_api_v1_routing.py::TestVersionedRoutesParity::test_every_root_route_has_v1_counterpart \
tests/unit/test_cli_run_params.py::test_run_params_match_cli_call \
--tb=short
status=$?
echo "status=$status" >> "$GITHUB_OUTPUT"
if [ "$status" -ne 0 ]; then
echo "::warning::Regen ran but drift tests still fail. Bot will open a tracking issue."
fi
exit 0
- name: Commit regen inline to source PR head ref
# Primary path. Same-repo PRs receive the regen patch directly on
# their head branch via git push, so the autofix rides into main
# with the source PR. No separate bot PR is opened.
if: >
steps.drift.outputs.drift == 'true' &&
steps.verify.outputs.changed == 'true' &&
steps.reverify.outputs.status == '0' &&
steps.forkcheck.outputs.is_fork == 'false'
id: inline_push
continue-on-error: true
env:
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_NUMBER: ${{ github.event.pull_request.number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# Configure a stable bot identity for the commit. The identity is
# intentionally distinct from any real user; the commit author
# uses the GitHub-actions noreply email so commits attribute to
# the bot, not the workflow's PAT owner.
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "chore(ci): regenerate contract drift allow-lists
Auto-applied by contract-drift-autofix.yml on PR #${PR_NUMBER}.
Regenerated via scripts/regen_contract_drift.py. Refs #1273.
Source CI run: ${RUN_URL}"
# --force-with-lease is the safe push variant: it rejects if the
# remote moved since we checked out, so a concurrent push from
# the PR author isn't clobbered. If the lease check fails the
# comment-fallback path will fire to surface the patch.
git push --force-with-lease origin "HEAD:${PR_HEAD_REF}"
echo "pushed=true" >> "$GITHUB_OUTPUT"
- name: Post drift patch as PR comment (fallback)
# Fallback path. Fires when:
# * the PR is from a fork (no push access to head ref), OR
# * the inline push step failed (lease conflict, branch protection,
# transient API outage).
# Body is idempotent on a hidden marker so re-runs edit the same
# comment rather than spamming the PR.
if: >
steps.drift.outputs.drift == 'true' &&
steps.verify.outputs.changed == 'true' &&
steps.reverify.outputs.status == '0' && (
steps.forkcheck.outputs.is_fork == 'true' ||
steps.inline_push.outcome == 'failure' ||
steps.inline_push.outputs.pushed != 'true'
)
id: comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
CHANGED_FILES: ${{ steps.verify.outputs.changed_files }}
LOC_DELTA: ${{ steps.verify.outputs.loc_delta }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
IS_FORK: ${{ steps.forkcheck.outputs.is_fork }}
INLINE_OUTCOME: ${{ steps.inline_push.outcome }}
with:
script: |
const fs = require('fs');
const path = require('path');
const marker = '<!-- contract-drift-autofix:patch -->';
const prNumber = Number(process.env.PR_NUMBER);
const changedFiles = (process.env.CHANGED_FILES || '').trim();
const locDelta = process.env.LOC_DELTA || '?';
const runUrl = process.env.RUN_URL;
const isFork = process.env.IS_FORK === 'true';
const inlineOutcome = process.env.INLINE_OUTCOME || '';
const reason = isFork
? 'PR is from a fork; the bot cannot push to fork head refs, so apply the patch below manually.'
: `Inline autofix push failed (\`${inlineOutcome || 'skipped'}\`). Apply the patch below manually.`;
const patchPath = path.join(process.env.RUNNER_TEMP, 'contract-drift.patch');
let patch = fs.readFileSync(patchPath, 'utf-8');
// Defensive cap: GitHub comment body limit is 65536 chars.
// The regen script caps the diff at 30 LOC so we are nowhere
// near this, but keep a guard in case the cap is ever raised.
const PATCH_CAP = 50000;
let truncated = false;
if (patch.length > PATCH_CAP) {
patch = patch.slice(0, PATCH_CAP);
truncated = true;
}
const body = [
marker,
'## Contract drift detected - proposed patch',
'',
reason,
'',
'Three contract tests act as drift detectors against the public CLI / API surface:',
'',
'- `tests/unit/test_readme_api_coverage.py::test_all_cli_commands_are_documented`',
'- `tests/unit/test_api_v1_routing.py::TestVersionedRoutesParity::test_every_root_route_has_v1_counterpart`',
'- `tests/unit/test_cli_run_params.py::test_run_params_match_cli_call`',
'',
`One or more failed on this PR. \`scripts/regen_contract_drift.py\` produced the patch below (${locDelta} LOC, cap: 30).`,
'',
'**Files changed:**',
'```',
changedFiles || '(none)',
'```',
'',
'### How to apply',
'',
'Either run the regen script locally:',
'',
'```bash',
'uv run python scripts/regen_contract_drift.py --fixture all',
'git add -A && git commit -m "chore(ci): regenerate contract drift allow-lists"',
'git push',
'```',
'',
'Or apply the patch directly:',
'',
'```bash',
`gh pr checkout ${prNumber}`,
'git apply <<\'PATCH\'',
patch.trimEnd(),
'PATCH',
'git add -A && git commit -m "chore(ci): regenerate contract drift allow-lists"',
'git push',
'```',
'',
truncated ? '_Patch truncated to fit comment limit; re-run regen locally for the full diff._' : '',
'',
'<details><summary>Full diff</summary>',
'',
'```diff',
patch.trimEnd(),
'```',
'',
'</details>',
'',
`**Source CI run:** ${runUrl}`,
'',
'_Refs #1273._',
].join('\n');
// Idempotency: edit the existing autofix comment in place
// rather than spamming a new one on every push.
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
core.info(`Updated existing drift-patch comment #${existing.id}.`);
} else {
const { data: created } = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
core.info(`Created drift-patch comment #${created.id}.`);
}
- name: Fall back to issue when regen could not fix the drift
if: >
steps.drift.outputs.drift == 'true' && (
steps.verify.outputs.changed != 'true' ||
steps.reverify.outputs.status != '0'
)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Title is idempotent on PR number so re-runs don't spam issues.
TITLE="Contract drift on PR #${{ github.event.pull_request.number }} could not be auto-patched"
EXISTING=$(gh issue list --search "$TITLE in:title" --state open --json number --jq '.[0].number // ""')
if [ -n "$EXISTING" ]; then
echo "::notice::Issue #$EXISTING already tracks this drift - skipping."
exit 0
fi
gh issue create \
--title "$TITLE" \
--label ci,contract-drift,bot \
--body "Contract drift was detected on PR #${{ github.event.pull_request.number }} but \`scripts/regen_contract_drift.py\` could not produce a clean patch.
Possible reasons:
- Drift exceeded the 30-LOC safety cap (real semantic change, not drift).
- Drift pattern is not one of the three the script handles.
- Regen ran but the targeted tests still fail (regen bug).
See workflow run ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} for details. Refs #1273."